Home › Blog › Stripe webhook security
Stripe webhook security: 7 mistakes that unlock access for free
October 12, 2026 · ~8 min read
The Stripe webhook is where your SaaS decides "this person paid, grant access." It's also a public endpoint on the internet that anyone can call. If it trusts what it receives without verifying, someone sends a fake checkout.session.completed and becomes a paying customer without paying a cent. Here are the 7 mistakes we see most — with the wrong code and the right code.
/api/stripe-webhook endpoint is live. An attacker POSTs a JSON mimicking a completed payment. If you don't verify the signature, your code grants access. Free, unlimited, and never showing up in the Stripe dashboard.
1. Not verifying the signature
The worst mistake: reading the request body and trusting it. Every legitimate Stripe event carries a Stripe-Signature header, an HMAC of the body with your webhook secret (whsec_…). Without checking it, the endpoint accepts any POST.
// ❌ trusts the raw body const event = JSON.parse(body); grantAccess(event.data.object); // ✅ verify the signature (Stripe SDK) let event; try { event = stripe.webhooks.constructEvent(body, sig, endpointSecret); } catch (e) { return res.status(400).send('invalid signature'); }
2. Verifying against an already-parsed body (breaks the signature)
The signature is computed over the exact bytes of the body. If a middleware (e.g. express.json()) parses the JSON first, you lose the raw body and verification always fails — then comes the temptation to "turn the check off to make it work." The fix is to pass the raw body on that route only.
// ✅ raw body on the webhook route only app.post('/api/stripe-webhook', express.raw({ type: 'application/json' }), handler);
In Next.js (App Router) use await req.text(); on Pages/Workers, await request.text() — never .json() before verifying.
3. Granting on the wrong event (the Pix/boleto case)
checkout.session.completed fires when the checkout is finished — but for asynchronous payments (Pix, boleto, bank debit) that happens before the money arrives. Granting there ships the product with no confirmed payment. Check payment_status and handle the async event.
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object;
if (s.payment_status === 'paid') grant(s); // card: already paid
break; // Pix/boleto: payment_status = 'unpaid' here, do NOT grant
}
case 'checkout.session.async_payment_succeeded':
grant(event.data.object); break; // money landed
}
4. No idempotency (processing twice)
Stripe retries the webhook if it doesn't get a fast 200 — and sometimes delivers the same event more than once. Without deduplication, you credit twice, grant twice, or send two emails. Store the event.id and ignore repeats.
if (await alreadyProcessed(event.id)) return res.json({ ok: true });
await markProcessed(event.id);
await grant(event.data.object);
5. Doing the heavy work before responding
If you generate a PDF, call an AI, or run a pipeline inside the handler, Stripe can hit its timeout (~a few seconds), think it failed and retry — multiplying the effect. Acknowledge fast (200) and process asynchronously.
// ✅ enqueue and respond immediately
await queue.enqueue({ sessionId: s.id });
return res.json({ received: true });
6. Trusting the amount/price from the event
Treat the event as a "trigger", not the source of truth for the amount. Retrieve the session/payment via the Stripe API (or check against your own order) and validate amount and currency before granting — this prevents tampering and grants the correct plan.
const full = await stripe.checkout.sessions.retrieve(s.id);
if (full.amount_total !== order.expectedAmount) return;
7. Wrong, exposed, or non-HTTPS secret
- The
whsec_is per endpoint and per environment (test ≠ live) — using the wrong one breaks everything; it's a server secret, never ships to the front-end or Git. - The endpoint must be HTTPS. The SDK's default time tolerance already blocks old replays — don't widen it without reason.
- Subscribe only to the events you use, and keep the endpoint fail-closed: invalid signature → 400, and nothing is granted.
stripe listen and stripe trigger checkout.session.completed from the Stripe CLI to fire real events in dev; then curl your endpoint with any body and no signature — it must return 400. If it returns 200 and grants something, the verification is missing. While you're securing payments, see the Supabase RLS guide and the Firebase Rules mistakes.
Is your payment flow actually closed?
SAAS SHIELD reviews your repository and checks the Stripe webhook, the "paid → unlock" gate and authorization — every flaw with its location, evidence and fix.
Request an audit →