# Webhook endpoints verify the incoming signature

- **Pattern:** `ab-002601` (`project-snapshot.abuse.webhook-signature-verified`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-25
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002601
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002601)

## Why it matters

A webhook handler that calls `req.json()` without verifying the signature will happily accept a forged payload from any attacker who can POST to the URL — and the URL is discoverable by scanning the site, reading the JavaScript bundle, or checking the provider dashboard's public integration docs. The forged `payment_succeeded`, `subscription_created`, or `checkout.session.completed` event then flows through the handler's normal happy-path logic, granting entitlements, marking orders paid, or corrupting records. AI coding tools scaffold webhook handlers without signature verification almost universally, because the end-to-end flow works fine during development: the real provider sends a real event, the handler processes it, the test passes. Stripe's own security documentation explicitly flags signature-skipping as the single most common webhook vulnerability, and the same pattern applies to Supabase auth webhooks, GitHub webhooks, and every other provider that signs its outbound requests.

## Severity rationale

High because a forged webhook event directly grants attackers whatever the handler grants — unauthorized product, unpaid subscriptions, free credits, or record tampering — at the cost of a single unsigned HTTP POST from anywhere on the internet.

## Remediation

For Stripe, use `stripe.webhooks.constructEvent` against the raw request body:
  ```ts
  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 new Response('Invalid signature', { status: 400 });
  }
  ```
  For GitHub, verify `x-hub-signature-256` with HMAC-SHA256 against the webhook secret. For Supabase, call `supabase.auth.getUser(token)`. Always read the raw body (not `req.json()`) before verification, and rotate the webhook secret if it ever leaks. Run `api-security` for a deeper pass on signed-request handling.

## Detection

- **ID:** `webhook-signature-verified`
- **Severity:** `high`
- **What to look for:** Enumerate webhook handlers under `app/api/webhooks/**/route.ts`, `app/api/stripe/**`, `app/api/webhook/**`, `pages/api/webhook*`, and any route whose path contains `webhook`, `stripe`, `resend`, `github`, `supabase-auth`. For each, verify it (a) reads the signing header (`stripe-signature`, `x-hub-signature-256`, `svix-signature`, `x-webhook-signature`), (b) calls a verification function — `stripe.webhooks.constructEvent(body, sig, secret)` / `svix.verify()` / manual HMAC with `crypto.timingSafeEqual`, and (c) returns an error response BEFORE any business logic on failure. Quote the verification line.
- **Pass criteria:** Handler reads the header, verifies against a secret from an env var, early-returns on failure. The raw request body is used (not parsed JSON — `constructEvent` requires raw bytes).
- **Fail criteria:** Handler calls `req.json()` / `request.body` on untrusted input and acts on the payload without prior signature verification. Header-presence check without HMAC verification fails. Calls verify but doesn't branch on the result. Reads `req.json()` first (which consumes the body) then tries to verify — signature always silently fails.
- **Skip (N/A) when:** No webhook endpoints — quote the directory walk and the absence of Stripe/Resend/GitHub/Supabase webhook deps.
- **Report even on pass:** Per handler: `"app/api/webhooks/stripe/route.ts: stripe.webhooks.constructEvent with STRIPE_WEBHOOK_SECRET; 400 on failure"`.
- **Detail on fail:** `"app/api/webhooks/stripe/route.ts calls req.json() and processes event.type with no signature verification"`.
- **Cross-reference:** For idempotency, replay protection, and signed-request patterns, run `api-security`.
- **Remediation:**
  ```ts
  // app/api/webhooks/stripe/route.ts
  export async function POST(req: Request) {
    const body = await req.text();  // raw body, NOT req.json()
    const sig = req.headers.get('stripe-signature');
    if (!sig) return new Response('Missing signature', { status: 400 });
    let event;
    try { event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); }
    catch { return new Response('Invalid signature', { status: 400 }); }
    // Now safe to process event
  }
  ```
  For GitHub: `crypto.timingSafeEqual(Buffer.from('sha256=' + hmac.digest('hex')), Buffer.from(header))`.

Taxons: project-snapshot, cryptography-and-secrets

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