Without webhook signature verification, any HTTP client on the internet can POST a fabricated payment_intent.succeeded event to your webhook endpoint and trigger order fulfillment, subscription activation, or credit grants — without paying anything. CWE-345 (Insufficient Verification of Data Authenticity) and CWE-352 (CSRF) both apply: an attacker replays or forges events, and your server acts on them as if they came from Stripe. PCI-DSS 4.0 Req 6.4 requires that payment event handling be protected against unauthorized manipulation. OWASP A07 (Identification and Authentication Failures) flags accepting unverified events as a complete authentication bypass.
Critical because an unverified webhook endpoint lets any actor forge a payment confirmation event and receive goods or services without being charged.
Call stripe.webhooks.constructEvent() as the absolute first step after reading the raw body. If it throws, return 400 immediately — never fall through to business logic.
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.text() // raw — must not be pre-parsed
const sig = (await headers()).get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Invalid signature', { status: 400 })
}
// Business logic only reaches here after verification
switch (event.type) { /* ... */ }
return new Response('OK', { status: 200 })
}
Do not wrap constructEvent in a catch that swallows the error and continues — that negates the entire protection.
ID: ecommerce-payment-security.payment-integration.webhook-signature-verify
Severity: critical
What to look for: Count every webhook endpoint file (search for route paths containing "webhook", "hook", "stripe", "paypal", "square" in API route directories). For each webhook handler, classify it as verified or unverified: check whether the request body and a signature header are passed to the provider's verification method before any business logic runs. For Stripe, look for stripe.webhooks.constructEvent(). For PayPal, look for paypal.webhooks.verifyWebhookSignature(). Check that if verification throws or returns an error, the handler returns a non-2xx response immediately without processing the payload.
Pass criteria: Every payment webhook endpoint verifies the request signature using the provider's official SDK method. No more than 0 webhook handlers should lack signature verification. If verification fails or the signature header is missing, the handler returns 400 or 401 without executing any business logic. Report even on pass: "X of Y webhook endpoints have signature verification."
Fail criteria: Any webhook endpoint reads and acts on the request body without first verifying the signature. This includes endpoints that verify some events but not others based on event type.
Do NOT pass when: Signature verification code exists but is wrapped in a try/catch that swallows the error and continues processing — this is NOT a pass because unverified payloads still reach business logic.
Skip (N/A) when: The project has no webhook integration detected — no webhook handler files found and no webhook-related dependencies or routes identified.
Detail on fail: Name each unverified webhook endpoint and the missing verification call. Example: "app/api/webhooks/stripe/route.ts processes payment.succeeded events without calling stripe.webhooks.constructEvent() — any HTTP client can spoof payment events"
Cross-reference: The API Security audit covers webhook endpoint hardening and request authentication patterns beyond payment-specific webhooks.
Remediation: Implement signature verification as the very first step in every webhook handler:
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text() // raw body — MUST NOT be pre-parsed
const sig = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response('Invalid signature', { status: 400 })
}
// Only now process the event
switch (event.type) { /* ... */ }
return new Response('OK', { status: 200 })
}
Store your webhook signing secret (whsec_*) in an environment variable — never hardcode it.