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.
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.
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.
project-snapshot.abuse.webhook-signature-verifiedhighapp/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.constructEvent requires raw bytes).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."app/api/webhooks/stripe/route.ts: stripe.webhooks.constructEvent with STRIPE_WEBHOOK_SECRET; 400 on failure"."app/api/webhooks/stripe/route.ts calls req.json() and processes event.type with no signature verification".api-security.// 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)).