Stripe Webhook Signature Verification: Do It, and Fail Closed
Stripe webhook signature verification means checking the Stripe-Signature header against your endpoint's whsec_ signing secret before you trust anything in the request. In Node, that's stripe.webhooks.constructEvent() on the raw request body, with a 400 response if it throws. Without it, anyone who finds your webhook URL can send a fake checkout.session.completed and get what your customers pay for.
This post covers why that works, correct code for a Next.js route handler and for Express, the ways verification quietly fails open, and how to fix the "signature verification failed" error.
Why Stripe webhook signature verification matters
Your webhook endpoint is a public URL that grants access, marks orders paid, or upgrades accounts when a request arrives. Stripe signs every event it sends. If your handler doesn't check that signature, it can't tell Stripe's request from anyone else's, and it will hand out the paid product to whoever sends the right JSON.
Stripe's own webhook docs put it the same way: without verification, an attacker could send fake events to trigger actions like fulfilling orders or granting account access.
Webhook paths aren't secret. They follow a few conventions (/api/webhooks/stripe, /api/stripe/webhook), and they sit in your repository for anyone who can read it. You can check your own endpoint in one command:
curl -i -X POST https://yourapp.com/api/stripe/webhook \
-H "Content-Type: application/json" \
-d '{"id":"evt_test","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test_fake"}}}'A verified handler answers 400. If yours answers 200, or your logs show it trying to fulfill cs_test_fake, it's open.
How Stripe signs a webhook
Each event arrives with a Stripe-Signature header like t=1492774577,v1=5257a8.... The t value is a timestamp. The v1 value is an HMAC-SHA256 of the timestamp, a dot, and the exact request body, keyed with your endpoint's signing secret.
constructEvent() recomputes that HMAC, compares it in constant time, and rejects the event if the timestamp is more than 5 minutes old (the libraries' default tolerance). Two consequences follow. You need the body byte for byte as Stripe sent it, and you need the right secret for the endpoint that received it.
The signing secret starts with whsec_. It is not your sk_ API key. In the Dashboard, open the endpoint under Webhooks in Workbench and click Reveal secret.
The code below handles snapshot events, the format where checkout.session.completed arrives with the session in data.object. If you chose the Thin payload when you created the destination, Stripe's SDK verifies with parseEventNotification() instead. The rules about raw bodies and secrets are the same.
Verify the signature in a Next.js App Router route handler
In the App Router, a route handler gets a standard Web Request. Read the body with request.text(), never request.json(), and pass that string to constructEvent():
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export async function POST(request: Request) {
// Fail closed: with no secret configured, no event is trusted.
if (!webhookSecret) {
console.error('STRIPE_WEBHOOK_SECRET is not set');
return new Response('Webhook not configured', { status: 500 });
}
const signature = request.headers.get('stripe-signature');
if (!signature) {
return new Response('Missing stripe-signature header', { status: 400 });
}
// The raw body, exactly as Stripe sent it.
const body = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`Webhook signature verification failed: ${message}`);
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded':
await fulfillCheckout(event.data.object.id);
break;
default:
break;
}
return Response.json({ received: true });
}
async function fulfillCheckout(sessionId: string) {
// Re-fetch from Stripe instead of trusting the event body.
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
});
if (session.payment_status === 'unpaid') return;
// Skip if this session was already fulfilled, then grant access.
}The Next.js docs note that, unlike API routes in the Pages Router, route handlers need no bodyParser configuration for this. The default Node.js runtime is what you want here. If you move the route to the Edge runtime, use await stripe.webhooks.constructEventAsync(...), since the Web Crypto API there only works asynchronously.
Verify the signature in Express
Express parses JSON only if you tell it to, and the usual app.use(express.json()) at the top of the file is exactly what breaks verification. Give the webhook route its own raw parser, and register it before the JSON middleware:
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET is not set');
const app = express();
// The webhook route comes first and gets the raw body as a Buffer.
app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, signature, webhookSecret);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send('Invalid signature');
}
if (event.type === 'checkout.session.completed') {
// fulfill using event.data.object.id, re-fetched from Stripe
}
res.json({ received: true });
});
// JSON parsing for every other route, registered after the webhook.
app.use(express.json());Note the return in the catch. Without it, Express sends the 400 and then keeps running the handler.
We ran both handlers above against signed test events: a valid one returned 200, while a tampered body, a re-serialized body, a missing header, the wrong secret and a ten-minute-old timestamp each returned 400.
The fail-open mistakes
Most broken handlers do call constructEvent(). They just have a path around it. These are the shapes to search your code for.
Skipping verification when the secret is missing
// Fails open: an unset env var turns verification off.
let event = JSON.parse(body);
if (endpointSecret) {
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
}You'll see this in sample code because it makes local testing easier. In production, a missing or misnamed environment variable on one deploy means every request is trusted. Fail closed instead: refuse to start, or return 500, as in the code above.
Skipping it when the signature header is missing
// Fails open: the attacker simply doesn't send the header.
if (req.headers['stripe-signature']) {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], secret);
} else {
event = JSON.parse(req.body);
}The header is part of the request, which means the sender controls it. A missing header is a reason to reject, never a reason to skip.
Catching the error and carrying on
try {
event = stripe.webhooks.constructEvent(req.body, signature, secret);
} catch (err) {
console.warn('Signature check failed, continuing', err.message);
event = JSON.parse(req.body);
}Any catch that doesn't end the request is a bypass. The fix is the one line in the examples above: return a 400 from inside the catch.
Verifying, then trusting fields you should re-fetch
A verified event proves Stripe sent it. It doesn't mean the payment is done. Stripe's fulfillment guide says to retrieve the Checkout Session from the API with line_items expanded, check payment_status before fulfilling, and handle checkout.session.async_payment_succeeded for payment methods such as bank debits, where checkout.session.completed arrives before the money does.
The same guide covers the success page. Customers don't always reach it, so fulfillment has to run from the webhook. If you also fulfill when the customer lands on your success URL, look the session up on the server by its id; never trust a ?paid=true in the address bar.
If you'd rather not read every handler, Tuvio's free scan flags Stripe webhook handlers with no signature check, verification that is skipped when the secret or signature header is missing, and a signature error that doesn't stop the handler. It reads one file per handler, so a check done in shared middleware in another file isn't seen, though an imported wrapper named like verifyStripeSignature or verifyStripeWebhook is accepted by name. The coverage page lists what else it checks.
Stripe webhook signature verification failed: how to fix it
The error usually reads No signatures found matching the expected signature for payload. Stripe's troubleshooting guide is blunt about it: at least one of the three things you passed is wrong. The body, the secret, or the header.
The wrong stripe webhook secret
Stripe names this as the most common cause. Every endpoint has its own signing secret, and they all start with whsec_, so it's easy to paste the wrong one.
| Where the event comes from | Which whsec_ secret to use |
|---|---|
stripe listen --forward-to localhost:... | The one the CLI prints when it starts |
| A Dashboard endpoint in test mode or a sandbox | That endpoint's secret, from Reveal secret |
| The same endpoint in live mode | A different secret: Stripe uses a separate one for test and live |
| A second endpoint (staging, another region) | That endpoint's own secret |
Log the last four characters of the secret your code actually loads, in the environment that's failing, and compare. Two more causes: a trailing space or newline pasted into the environment variable (stripe-node mentions whitespace in the error when it finds some), and a changed variable that never reached the running app because you didn't redeploy.
The body was parsed before you verified it
This is the other usual cause in Node apps. The signature covers the exact bytes, so JSON.parse followed by JSON.stringify doesn't give you the same body back: whitespace and newlines change, and some frameworks also reorder keys or change the encoding.
- Express:
app.use(express.json())registered before the webhook route parses the body first. stripe-node then saysWebhook payload must be provided as a string or a Buffer. Move the webhook route above it and useexpress.raw({ type: 'application/json' }). - Next.js App Router: use
await request.text(). Callingrequest.json()and re-stringifying fails. - Next.js Pages Router: disable the built-in body parser for that API route and read the raw buffer, as in stripe-node's Pages Router example.
Something between Stripe and your code changed the body
API gateways, proxies, and tools that forward or replay webhooks can re-encode the JSON. stripe-node's error message says it directly: if a third-party tool forwards the request, the exact body, including JSON formatting and newline style, has to be preserved. If verification works locally with stripe listen but fails in production, look at what sits in front of your app.
Timestamp outside the tolerance zone
The signature matched, but the event is older than the tolerance. Either the server clock has drifted, so sync it with NTP, or you're verifying long after the request arrived, for example in a queue worker. Verify in the request handler, then queue the work. Don't fix this by passing a tolerance of 0: Stripe's docs warn that 0 turns the recency check off completely.
Replays and duplicate events
The timestamp is part of what Stripe signs, so an attacker who captures a real request can't change it. With the default 5-minute tolerance, a captured request stops working after a few minutes. Inside that window it can still be sent again, which is one more reason to deduplicate. When Stripe retries a delivery, it signs it again with a fresh timestamp, so legitimate retries still pass.
Duplicates are different: Stripe can deliver the same event more than once, and its docs say to log the event ids you've processed and skip ones you've already seen. A table with the event id as primary key does it:
create table processed_stripe_events (
event_id text primary key,
received_at timestamptz not null default now()
);
-- In the handler, in the same transaction as the fulfillment:
insert into processed_stripe_events (event_id)
values ($1)
on conflict (event_id) do nothing
returning event_id;No row returned means you've seen that event: return 200 and stop, so Stripe stops retrying. Doing the insert in the same transaction as the fulfillment matters. If fulfillment fails, both roll back, and Stripe's retry gets a second chance. Stripe also notes that two separate events can occasionally describe the same thing, so make the fulfillment itself safe to run twice for one Checkout Session, for example with a unique constraint on the session id in your orders table.
Stripe webhook verification checklist
STRIPE_WEBHOOK_SECRETis set in every environment, starts withwhsec_, and is server-only, never behind aNEXT_PUBLIC_orVITE_prefix.- The handler verifies with
constructEvent()on the raw body:request.text()in Next.js,express.raw()in Express. - A missing secret, a missing header, or a thrown error each ends the request with a 400 or 500.
- The forged
curlrequest from the top of this post gets a 400. - Fulfillment re-fetches the Checkout Session and checks
payment_status. - Processed event ids are recorded, and fulfillment is safe to run twice.
- Tolerance is the default, and never
0. - Optionally, accept webhook requests only from Stripe's published IP addresses, which Stripe recommends alongside signature checks.
For where else secrets end up in an AI-built app, see how to hide an API key in the frontend, and work through the vibe coding security checklist before you launch.