# Webhook handlers verify the signature

- **Pattern:** `ab-000252` (`ai-slop-cost-bombs.cache-idempotency.webhook-signature-verified`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000252
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000252)

## Why it matters

An unverified webhook endpoint is an open forged-event injection point: anyone who discovers the URL can POST a `payment_intent.succeeded` event with a fabricated payment amount and trigger fulfillment, subscription upgrades, or user privilege escalation without paying a cent. CWE-345 (Insufficient Verification of Data Authenticity) is the direct mapping. OWASP A07:2021 (Identification and Authentication Failures) applies: the server cannot distinguish a real Stripe event from a crafted one without signature verification. This is not a theoretical risk — Stripe explicitly documents the attack in their webhook security guide and provides `constructEvent` as the countermeasure.

## Severity rationale

High because a forged webhook event can trigger financial side effects — fulfillment, subscription activation, refunds — with no authentication required beyond knowing the endpoint URL.

## Remediation

Always verify the signature using the provider's SDK before reading the event body. For Stripe, use `stripe.webhooks.constructEvent` with the raw body string — not `req.json()`, which reparsing breaks the HMAC.

```ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const body = await req.text()  // raw body required for HMAC
  const sig = req.headers.get('stripe-signature')
  if (!sig) return Response.json({ error: 'Missing signature' }, { status: 400 })

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return Response.json({ error: 'Invalid signature' }, { status: 400 })
  }
  // Safe to process — event is authentic
}
```

For Clerk or other providers using Svix, replace the Stripe call with `new Webhook(secret).verify(body, headers)` — the pattern is identical.

## Detection

- **ID:** `webhook-signature-verified`
- **Severity:** `high`
- **What to look for:** Walk all webhook handler files. Count all webhook handlers and verify each handler calls a signature verification function before processing the body: `stripe.webhooks.constructEvent(`, `svix.Webhook(...).verify(`, `crypto.createHmac(...).digest()` compared to a header signature, `clerk.webhooks.verify(`. Count handlers that process events without verification.
- **Pass criteria:** 100% of webhook handlers verify the signature before processing. Report: "X webhook handlers inspected, Y verify signatures, 0 unverified."
- **Fail criteria:** At least 1 webhook handler reads the body and processes events without checking the signature header.
- **Skip (N/A) when:** Project has 0 webhook routes detected.
- **Cross-reference:** For broader webhook security, the API Security audit (`api-security`) covers signature verification patterns.
- **Detail on fail:** `"1 unverified webhook: app/api/webhooks/stripe/route.ts processes events without calling stripe.webhooks.constructEvent() — anyone who can POST to the URL can fake events"`
- **Remediation:** An unverified webhook endpoint is an open mailbox — anyone can forge events. Always verify the signature:

  ```ts
  import Stripe from 'stripe'

  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

  export async function POST(req: Request) {
    const body = await req.text()
    const sig = req.headers.get('stripe-signature')!
    let event
    try {
      event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
    } catch (err) {
      return Response.json({ error: 'Invalid signature' }, { status: 400 })
    }
    // ... safe to process event
  }
  ```

## External references

- cwe CWE-345
- owasp A07

Taxons: access-control, injection-and-input-trust

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