Stripe webhook signature not verified: how to fix it
By Obscura Security · Updated · Stripe, Next.js, Express, Supabase
Short answer
If your webhook handler trusts the JSON it receives without checking the Stripe-Signature header, anyone who finds the URL can send a fake checkout.session.completed event and get paid access for free. Read the raw request body and verify it with stripe.webhooks.constructEvent and your endpoint's signing secret before you act on it.
A Stripe webhook is a URL on your site that Stripe calls when something happens, such as a completed checkout. Your code then upgrades the user, sends the receipt, or unlocks the course. If that URL doesn’t check who is calling it, anyone can call it.
Why this happens
The endpoint, often /api/webhooks/stripe or /api/stripe-webhook, is public. A quick version of the handler reads the JSON, looks at type, and acts:
const event = await req.json();
if (event.type === "checkout.session.completed") {
await grantAccess(event.data.object.client_reference_id);
}
That works in testing, so AI tools often stop there. But a person who finds the URL can send the same JSON with curl, using any user ID they like, and your app treats it as a real payment.
Stripe signs every webhook it sends. It computes a signature over the request body with your endpoint’s secret and puts it in the Stripe-Signature header. Checking that signature is how you know the request came from Stripe. The check also rejects old timestamps; the default tolerance is five minutes.
How to check your own app
- Search your code for
constructEventorconstructEventAsync. If your webhook handler doesn’t call one of them, it isn’t verifying signatures. - Send an unsigned request to your own endpoint with a harmless event type:
curl -i -X POST https://YOUR-APP.com/api/webhooks/stripe \
-H "Content-Type: application/json" \
-d '{"id":"evt_test","type":"ping"}'
A correct handler answers 400. If you get 200, it accepts unsigned requests. Only run this against your own app, and don’t use a real event type like checkout.session.completed.
The fix
Next.js App Router
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body, not req.json()
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
// Only now is it safe to look at event.type
return new Response(null, { status: 200 });
}
In the older Pages Router, turn off the body parser for the route (export const config = { api: { bodyParser: false } }) and read the raw body yourself.
Express
Verification needs the raw body, so give this one route express.raw. If express.json() is applied to the whole app before it, the body is already parsed and verification fails.
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send("Invalid signature");
}
// handle event
res.sendStatus(200);
}
);
app.use(express.json()); // after the webhook route
Supabase Edge Functions (Deno)
Deno has no synchronous crypto, so use the async version:
const cryptoProvider = Stripe.createSubtleCryptoProvider();
const event = await stripe.webhooks.constructEventAsync(
await req.text(),
req.headers.get("stripe-signature")!,
Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
undefined,
cryptoProvider
);
Stripe doesn’t send a Supabase login token, so the function has to be deployed without JWT verification (supabase functions deploy stripe-webhook --no-verify-jwt). That is safe only because the Stripe signature check replaces it.
Re-fetch the object from Stripe
A verified event proves Stripe sent it. It’s still good practice to ask Stripe for the current state of the thing you’re about to act on, rather than relying on the event’s copy:
if (event.type === "checkout.session.completed") {
const session = await stripe.checkout.sessions.retrieve(event.data.object.id);
if (session.payment_status === "paid" && session.client_reference_id) {
await grantAccess(session.client_reference_id);
}
}
For payment methods that confirm later (some bank debits, for example), checkout.session.completed can arrive before the money has. Watch for checkout.session.async_payment_succeeded as well. Trials can return no_payment_required; decide what you want to do with those.
Make the handler safe to run twice
Stripe can deliver the same event more than once, and retries events that don’t get a 2xx reply. Write the handler so a repeat does no harm: set a user’s plan to pro rather than adding 30 credits, or store each event.id in a table with a unique constraint and skip ones you’ve seen.
Mistakes to avoid
- Verifying a re-serialised body.
JSON.stringify(await req.json())isn’t the original bytes, and the signature won’t match. - Using the wrong secret. The test endpoint, the live endpoint and
stripe listeneach have their ownwhsec_value. - Catching the verification error and carrying on. The
catchmust return, not log and continue. - Taking the user ID from the event body without a signature. Anything in an unsigned body can be made up.
- Leaving the old unverified route deployed. AI tools sometimes create a second webhook file. Search for every route that handles Stripe events.
Common questions
- Where do I find the webhook signing secret?
- Open the webhook endpoint in your Stripe Dashboard (Developers, then Webhooks) and reveal its signing secret, which starts with whsec_. Test mode and live mode have different secrets. If you use the Stripe CLI with stripe listen, it prints its own whsec_ secret for local testing.
- Why does constructEvent keep failing with 'No signatures found matching the expected signature'?
- Almost always because the body was parsed or changed before verification. Stripe signs the exact raw bytes it sent, so you must pass the unparsed body. The next most common causes are using the wrong secret (test vs live, or the CLI's secret) and a proxy that rewrites the body.
- Should I return 200 or 400 when the signature is bad?
- Return 400 and do nothing else. A forged request should never get a success reply. Real events from Stripe will carry a valid signature, so they are unaffected.
- Do I still need the webhook if I redirect to a success page?
- Yes. A success page can be visited by anyone, and a real customer can close the tab before the redirect. The verified webhook is the reliable signal that a payment happened.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].