Stripe webhook signature verification fails closed: a failed or skipped verification never reaches event handling
Why it matters
Stripe tells you plainly that an endpoint must verify the Stripe-Signature header before it trusts an event, and most AI-generated handlers do call constructEvent. The failure this check targets is the quieter one: verification exists in the file but the code path around it fails open. The common shapes are if (process.env.STRIPE_WEBHOOK_SECRET) { verify } so an unset secret in a preview or misconfigured production deploy silently turns verification off, a catch block that only logs (console.error) and then falls through to the handler, and a fallback such as event = JSON.parse(body) when constructEvent throws. Each one converts "reject the request" into "process it anyway". This is CWE-345 (Insufficient Verification of Data Authenticity) and OWASP A08, and a hand-rolled verifier that compares signatures with == or === adds the timing-comparison weakness CWE-208. Plain existence of signature verification is covered by the Webhook Signature Verification check in this audit; this check asks the different question of whether the code still rejects when verification does not succeed. A forged event is a free subscription, a free fulfilled order, or a forged refund state, with no trace in Stripe because the payment never happened.
Severity rationale
Critical because a verification step that can be skipped, swallowed, or bypassed is functionally absent: anyone who can POST to the endpoint can forge `checkout.session.completed` or `invoice.paid` and receive paid access without paying.
Remediation
Make verification unconditional and make every failure return before any event handling. Read the raw body with await req.text(), read the stripe-signature header, and call stripe.webhooks.constructEvent(body, sig, secret) inside a try whose catch returns a 400 response (or throws) and nothing else. Read the secret with a hard requirement (const secret = process.env.STRIPE_WEBHOOK_SECRET; if (!secret) throw new Error('STRIPE_WEBHOOK_SECRET is required')) so a missing secret breaks the deploy loudly instead of disabling the check. Never assign event from JSON.parse(body). Let the Stripe SDK compare signatures; if you hand-roll verification, parse the t= and v1= parts, accept only the v1 scheme, and compare with crypto.timingSafeEqual over equal-length buffers.
export async function POST(req: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET
if (!secret) return new Response('Webhook not configured', { status: 500 })
const body = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, req.headers.get('stripe-signature') ?? '', secret)
} catch {
return new Response('Invalid signature', { status: 400 })
}
// only now handle event
}
DetectionPro
The detection method for this pattern is part of AuditBuffet Pro.
The full method (what to look for, pass and fail conditions, and how results get reported) ships inside the Stripe Webhooks & Billing Integrity audit. The pattern definition, severity rationale, and remediation remain openly licensed under CC-BY-4.0.
External references
- cwe · CWE-345 — Insufficient Verification of Data Authenticity
- cwe · CWE-208 — Observable Timing Discrepancy
- owasp:2021 · A08 — Software and Data Integrity Failures
- external · stripe-webhooks-signatures — Stripe docs: Check the webhook signatures to verify events are from Stripe
Taxons
History
- 2026-10-05·v1.0.0·Initial authoring — fail-open verification shapes (conditional on secret, swallowing catch, JSON fallback, non-constant-time hand-rolled compare); deliberately does not re-implement plain verification-exists, covered by ab-002262.·by stripe-billing-integrity-v1-build