Stripe webhook: "No signatures found matching the expected signature"
Your webhook handler calls stripe.webhooks.constructEvent and every event fails with this error. The tempting fix is to delete the check and read the JSON directly. Then anyone who finds the URL can send a fake checkout.session.completed and get a paid plan without paying.
Cause 1: the body was parsed before you verified it
Stripe signs the exact bytes it sent. If a framework parses the body into an object and you turn it back into a string, the spacing and key order can change, and the signature no longer matches. Verify over the raw body.
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.json();
const event = stripe.webhooks.constructEvent(
JSON.stringify(body), // not the bytes Stripe signed
req.headers.get('stripe-signature')!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
}export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response('Invalid signature', { status: 400 });
}
// handle event.type here
return new Response(null, { status: 200 });
}In Express, app.use(express.json()) runs before your route and parses the body. Give the webhook route its own raw parser and register it before the JSON middleware.
app.post('/webhooks/stripe', 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 {
return res.status(400).send('Invalid signature');
}
res.sendStatus(200);
});
app.use(express.json()); // after the webhook routeCause 2: the wrong signing secret
Each webhook endpoint has its own whsec_ secret, and test mode and live mode endpoints have different ones. The Stripe CLI prints yet another secret when you run stripe listen. Use that one locally and the endpoint's secret from the Dashboard in production. The secret key (sk_) is a different value and never works here.
Why you should never skip the check
A webhook URL is not a secret. It shows up in logs, docs and browser history, and attackers guess common paths like /api/webhooks/stripe. Without signature verification, a single forged request can mark an order as paid, grant credits or upgrade a plan. If you also handle retries, store the event id so the same event is processed once: see how to stop charging customers twice.
Verify it
stripe listen --forward-to localhost:3000/api/webhooks/stripe, then stripe trigger checkout.session.completed and expect a 200. Then send the same path a request without a signature, for example curl -X POST localhost:3000/api/webhooks/stripe -d '{}', and expect a 400 with no change in your database.How Zelsis catches this
Zelsis flags payment webhook handlers that read the request body without a signature check, and handlers that verify over JSON.stringify of a parsed body, on the pull request.
- FINTECH-04Payment webhook processes events without verifying the signature
- SAAS3-01Webhook signature verified over re-serialized JSON instead of the raw body
Check your own repo
Public repositories scan free. Paste a GitHub URL on the dashboard and every finding comes with the file, the line and a fix.
Scan a repository free