Open your webhook handler and look for a call to stripe.webhooks.constructEvent(). If the
handler reads the request body and acts on event.type without calling it, your endpoint
will believe anyone who posts to it.
A Stripe webhook URL is public. Nothing stops a stranger sending this:
curl -X POST https://yourapp.com/api/stripe-webhook \
-H "content-type: application/json" \
-d '{"type":"checkout.session.completed","data":{"object":{"customer":"cus_theirId"}}}'
If your handler trusts that, they have just given themselves a paid account. Signature verification is the only thing that distinguishes a real Stripe event from a forged one.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
export async function POST(req) {
const raw = await req.text() // the RAW body, not JSON
const sig = req.headers.get('stripe-signature')
let event
try {
event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET,
)
} catch (err) {
return new Response(`Invalid signature: ${err.message}`, { status: 400 })
}
// Only now is `event` trustworthy.
if (event.type === 'checkout.session.completed') { /* ... */ }
return new Response('ok')
}
Stripe signs the exact bytes it sent. Any middleware that parses the body first — most
commonly express.json() — hands constructEvent a re-serialised object, and
verification fails for every legitimate event.
The symptom is confusing: real webhooks start returning 400 while forged ones would too, so it looks like the check is working. It is failing closed, which is safe, but your app stops receiving payments.
// Express: mount the raw parser on the webhook route only
app.post('/api/stripe-webhook',
express.raw({ type: 'application/json' }),
handler,
)
app.use(express.json()) // everything else, after
The dangerous resolution is the one an AI agent reaches for when asked to "fix the failing webhook": remove the verification. That makes the errors stop and the endpoint forgeable, and the diff looks like a cleanup.
// TODO: verify signature
is not verification, and it is surprisingly common in handlers written quickly.req.text() in Next.js App Router,
express.raw() in Express, and no global JSON parser in front of it.catch that logs and then carries on
processing the event is the same as no verification at all.Stripe's CLI lets you prove it end to end. stripe listen --forward-to sends genuinely
signed events; a plain curl like the one above should get a 400.
ProdGuard is a free, open-source command that reads your repository and fails the build when a webhook handler has no signature verification — and it does not accept a comment as proof:
npx prodguard check
It also checks eleven other things AI agents remove, including Row Level Security. Zero dependencies, MIT, nothing leaves your machine. What it does and does not catch.