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
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
§ 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.
- Open your webhook route file and search for
constructEventorverifyHeader. If it is not there, the endpoint is unverified. - 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 useexpress.raw({ type: 'application/json' })on that route only. - Confirm the user is found by
customerorclient_reference_id, not by an email from the payload. - 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 →§ 04 — Related
Other things we find
Prices sent from the browser to the checkout
Your create-checkout endpoint accepts amount, price or total in the request body.
Supabase Row Level Security is off, or has no policy
Your app works, and the Supabase dashboard shows "RLS disabled" or "No policies" next to your tables.
Read the fix → CRITICAL Exposed credentialsSecret keys shipped to the browser
You have an environment variable named something like NEXT_PUBLIC_OPENAI_API_KEY or VITE_STRIPE_SECRET_KEY.