Webhook endpoints verify the incoming signature
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:
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 containswebhook,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 withcrypto.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 —
constructEventrequires raw bytes). - Fail criteria: Handler calls
req.json()/request.bodyon 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. Readsreq.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:
For GitHub:// 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 }crypto.timingSafeEqual(Buffer.from('sha256=' + hmac.digest('hex')), Buffer.from(header)).
Taxons
History
- 2026-04-22·v1.0.0·Initial authoring via Phase 9 consequence-first restructure·by editorial
- 2026-04-23·v1.1.0·Phase 9.1 — moved from security bucket to abuse bucket; pairs with webhook-idempotency under cost/abuse framing.·by phase-9-1-stack-scan-v3-1
- 2026-04-25·v1.1.1·v3.1.0 pre-ship trim — prose compression for under-80K MCP cap; merged overlapping Fail-criteria / Do-NOT-pass-when sections; compressed enumeration prose; one remediation example per pattern. No semantic change; anti-sycophancy guards preserved.·by phase-9-1-stack-scan-v3-1