CRITICAL Stripe · Next.js / Express / Supabase Edge Functions

Stripe webhook accepts unverified events

What you'd see: Your webhook route reads the request body and acts on it directly.

§ 01 — What's actually happening

Stripe tells your server about payments by POSTing JSON to a URL you give it. That URL is public — it has to be, Stripe needs to reach it. The signature header is what proves a request actually came from Stripe. If you never check it, your "payment received" endpoint is just a form anyone on the internet can submit.

Why an AI tool writes it this way

Ask an AI to "handle the Stripe webhook and upgrade the user" and it writes exactly that: it handles the webhook and upgrades the user. Signature verification was not in the request, so it is not in the code. The result runs perfectly in testing, because in testing the only thing calling your endpoint is Stripe.

What it costs you

A single curl command grants a paid plan for free. Worse, the same request can be replayed indefinitely, and because the code trusts an email address from the request body, an attacker can upgrade any account they like — including yours.

§ 02 — Before & after

The payment anyone can fake

Illustrative code, written for this page — never a client's project.

AI-generated

  • Accepts any JSON without checking it came from Stripe
  • The same event can be replayed over and over
  • Trusts an email address the caller controls
export async function POST(req) { const event = await req.json(); if (event.type === 'checkout.session.completed') { await upgradeUserToPro(event.data.object.customer_email); } return new Response('ok'); }

Human-reviewed

  • Signature verified against your webhook secret — forged calls are rejected
  • Raw body used for verification, not the parsed object
  • Replay protection: an event ID is only processed once
  • Customer identified by Stripe's ID, never by user-supplied email
export async function POST(req) { const raw = await req.text(); // raw body, not req.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', { status: 400 }); } if (await alreadyProcessed(event.id)) { // replay protection return new Response('ok'); } if (event.type === 'checkout.session.completed') { const session = event.data.object; await upgradeUserByStripeCustomer(session.customer); // Stripe's own ID } await markProcessed(event.id); return new Response('ok'); }

§ 03 — Check your own

How to tell in two minutes

You don't need us to run these. If any of them come back the wrong way, you have this problem.

  1. Open your webhook route file and search for constructEvent or verifyHeader. If it is not there, the endpoint is unverified.
  2. Check that the handler reads the raw body. Frameworks that auto-parse JSON break signature checking even when the code looks right — in Next.js App Router use await req.text(); in Express use express.raw({ type: 'application/json' }) on that route only.
  3. Confirm the user is found by customer or client_reference_id, not by an email from the payload.
  4. Look for a table or cache recording processed event IDs. Stripe retries on any non-2xx response, so without it a network blip can double-credit an account.

Found it in your project? Fixing this one properly usually takes an engineer under an hour. Finding the other nine takes longer — which is what the free health check is for.

Get my free health check

Find out where you actually stand.

Free. No obligation. Reviewed by experienced engineers.

Get my free health check