Stripe webhook signature verification
A very common AI-generated bug: the handler parses JSON first, then re-stringifies it to check Stripe’s signature. Re-encoding changes the bytes, verification always fails, and the “fix” is to log a warning and carry on. Now anyone can POST a fake checkout.session.completed and unlock paid features.
1. Use the raw body
// Express
app.post('/api/stripe-webhook', express.raw({ type: 'application/json' }), handler);
// Next.js route handler
const raw = await req.text();
2. Verify, and reject on failure
let event;
try {
event = stripe.webhooks.constructEvent(raw, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send('Invalid signature'); // never continue
}
3. Handle retries idempotently
Stripe retries deliveries, so the same event can arrive more than once.
create table payment_events (event_id text primary key, received_at timestamptz default now());
await db.tx(async (t) => {
const inserted = await t.result('insert into payment_events(event_id) values($1) on conflict do nothing', [event.id]);
if (inserted.rowCount === 1) await t.none("update orders set status='paid' where id=$1", [orderId]);
});
If your own database call fails, return 500 so Stripe tries again.
4. Prove it
- Unsigned event → 400, order unchanged
- Event signed with the wrong secret → 400, order unchanged
- Genuine signed event → 200, order paid
- Same event replayed → 200, recorded once
Use stripe trigger or the Stripe CLI’s stripe listen to send genuine test events locally.