# Stripe webhook signature verification fails closed: a failed or skipped verification never reaches event handling

- **Pattern:** `ab-002648` (`stripe-billing-integrity.webhook-authenticity.stripe-webhook-verification-fails-closed`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-10-05
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002648
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002648)

## 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.

```ts
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
}
```

## Detection

**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](https://auditbuffet.com/audits/stripe-billing-integrity). The pattern definition, severity rationale, and remediation remain openly licensed under CC-BY-4.0.

## External references

- cwe CWE-345
- cwe CWE-208
- owasp A08
- external stripe-webhooks-signatures — https://docs.stripe.com/webhooks

Taxons: cryptography-and-secrets, access-control

HTML version: https://auditbuffet.com/patterns/ab-002648
