Stripe webhook fails: No signatures found matching the expected signature for payload
After a harmless-looking Express refactor, every Stripe webhook failed verification and paid bookings stayed pending. A global JSON parser consumed the body before constructEvent saw it. How we found it, fixed the route and replayed the missed events.
On a Tuesday afternoon our support queue for a small tour-booking platform filled with the same
complaint: customers were charged, received Stripe's receipt email, but their booking in our app
still said "Awaiting payment". The booking only flips to confirmed when our server receives the
payment_intent.succeeded webhook, so the problem was on the receiving side.
The Stripe Dashboard showed every delivery to our endpoint failing with HTTP 400 since about 11:40. Our logs had the reason on every request:
StripeSignatureVerificationError: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
If a webhook request is being forwarded by a third-party tool, ensure that the exact request body,
including JSON formatting and new line style, is preserved.
Learn more about webhook signing and explore webhook integration examples for various frameworks
at https://docs.stripe.com/webhooks/signature
The deploy at 11:32 had been a refactor of how we build the Express app. Nobody had touched the webhook handler itself.
Reproducing it
With the Stripe CLI we could reproduce it locally in under a minute:
stripe listen --forward-to localhost:3000/webhooks/stripe
# copy the whsec_... secret it prints into STRIPE_WEBHOOK_SECRET, restart the app
stripe trigger payment_intent.succeeded
The CLI showed [400] POST http://localhost:3000/webhooks/stripe for each forwarded event, and the
app logged the same error. Reverting the refactor commit made the 400s disappear.
What we ruled out
- Wrong signing secret. This is the most common cause, so we checked first. The production
value matched the "Signing secret" shown for that endpoint in the Dashboard, and locally we used the
secret printed by
stripe listen. A wrong secret also could not explain why the refactor commit alone flipped the behaviour. - Clock skew. A skewed clock produces a different message,
Timestamp outside the tolerance zone, andchronyc trackingon the hosts showed offsets under 5 ms. - A proxy rewriting the body. Requests go through an AWS ALB and nginx, neither of which modifies request bodies. Replaying a captured request directly against the Node process failed the same way.
Root cause
The refactor moved app.use(express.json()) into a shared createApp() helper that runs before
any routes are mounted. The JSON parser therefore consumed and parsed the webhook body before our
route handler ran. The first version of the refactor actually failed with a different stripe-node
error (Webhook payload must be provided as a string or a Buffer ... Payload was provided as a parsed JavaScript object instead). To make that go away, the handler was changed to pass
JSON.stringify(req.body). That produced a string, but not the same bytes Stripe signed: whitespace
and formatting differ from the original payload, so the HMAC could never match.
What is happening under the hood
Stripe signs each delivery with HMAC-SHA256 using the endpoint's signing secret. The signed message
is the timestamp, a literal ., and the exact request body bytes. The result is sent in the
Stripe-Signature header in the form t=<timestamp>,v1=<signature>. On our side,
stripe.webhooks.constructEvent(body, header, secret) recomputes the HMAC over whatever body we
pass and compares it, in constant time, to the v1 values.
HMAC is computed over bytes, not over JSON meaning. These two bodies are semantically identical and produce completely different signatures:
Body passed to constructEvent |
Bytes | Result |
|---|---|---|
Raw Buffer from the request |
Exactly what Stripe sent, including its spacing and newlines | Signature matches |
JSON.stringify(req.body) |
Re-serialised: compact spacing, possibly different escaping | Signature mismatch |
req.body object |
Not a string or Buffer at all | Payload type error |
Any middleware that reads the stream first (express.json(), body-parser, a logging middleware
that buffers and re-emits, a framework that decodes JSON automatically) breaks verification unless
it preserves the original bytes.
The verification also checks recency. stripe-node's default tolerance is 300 seconds between the signed timestamp and the current time, which is what blocks replays of an old captured request.
Environment
| Component | Version / value |
|---|---|
| OS | Amazon Linux 2023 on EC2 behind an ALB |
| Runtime | Node.js 22.11.0 |
| Framework | Express 4.21.1 |
| Stripe SDK | stripe (stripe-node) 17.3.0 |
| Endpoint | POST /webhooks/stripe, events payment_intent.succeeded, charge.refunded |
| Relevant config | app.use(express.json()) registered globally in createApp() |
Minimal reproduction
// app.js (broken)
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // sk_test_xxx in development
const app = express();
app.use(express.json()); // parses every body, including the webhook
app.post('/webhooks/stripe', (req, res) => {
const sig = req.get('stripe-signature');
try {
// re-serialising the parsed object does not restore the original bytes
const event = stripe.webhooks.constructEvent(
JSON.stringify(req.body), sig, process.env.STRIPE_WEBHOOK_SECRET,
);
res.sendStatus(200);
} catch (err) {
console.error(err);
res.status(400).send(`Webhook Error: ${err.message}`);
}
});
Who is affected
Any integration that verifies provider signatures over the body: Stripe, GitHub, Shopify, Slack and
most webhook senders use the same "HMAC over raw bytes" approach. Express apps with a global JSON
parser, Next.js API routes with the default body parser, NestJS without rawBody: true, and
serverless platforms that hand you a decoded body are the usual suspects. Integrations that verify by
calling the provider's API back (instead of HMAC) do not have this problem.
The fix
We mounted the webhook route with its own raw parser before the global JSON parser, and passed the
Buffer straight to constructEvent:
// app.js (fixed)
import express from 'express';
import Stripe from 'stripe';
import { enqueueStripeEvent } from './queue.js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();
// 1. Webhook first, with a raw body parser scoped to this route only.
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json', limit: '1mb' }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer, untouched
req.get('stripe-signature'),
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch (err) {
req.log.warn({ err: err.message }, 'stripe signature verification failed');
return res.status(400).send('invalid signature');
}
await enqueueStripeEvent(event); // process asynchronously, respond fast
return res.sendStatus(200);
},
);
// 2. Everything else gets parsed JSON as before.
app.use(express.json());
The diff in the shared helper was two lines:
export function createApp() {
const app = express();
- app.use(express.json());
+ mountWebhooks(app); // raw-body routes first
+ app.use(express.json()); // then the global parser
mountApiRoutes(app);
return app;
}
Order matters in Express: middleware registered with app.use runs for every route mounted after
it, so the raw route has to be registered first.
Alternatives and trade-offs
-
Keep the global parser and capture the raw bytes.
express.json()accepts averifycallback that receives the raw buffer before parsing:app.use(express.json({ verify: (req, _res, buf) => { if (req.originalUrl.startsWith('/webhooks/')) req.rawBody = buf; }, }));This works and suits apps with many webhook providers, but it keeps an extra copy of every matching body in memory and is easy to break when someone changes the path prefix.
-
Separate webhook service. Running webhooks in their own small process removes the shared middleware problem entirely, at the cost of another deployable.
We rejected skipping verification on a "trusted" network path. The endpoint is public by design;
without the signature, anyone can post a fake payment_intent.succeeded.
How to test the fix
A unit test using stripe-node's helper for generating a valid test header catches both the middleware order and any future body mutation:
import request from 'supertest';
import Stripe from 'stripe';
import { createApp } from '../src/app.js';
const stripe = new Stripe('sk_test_xxx');
const secret = 'whsec_test_secret';
process.env.STRIPE_WEBHOOK_SECRET = secret;
test('accepts a correctly signed raw payload', async () => {
const payload = JSON.stringify({ id: 'evt_test_1', object: 'event', type: 'payment_intent.succeeded',
data: { object: { id: 'pi_test_1', object: 'payment_intent' } } }, null, 2);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
await request(createApp())
.post('/webhooks/stripe')
.set('Content-Type', 'application/json')
.set('Stripe-Signature', header)
.send(payload)
.expect(200);
});
The payload is pretty-printed on purpose: if anything re-serialises it, the bytes change and the test fails. End to end, we re-ran the CLI flow:
stripe trigger payment_intent.succeeded
# expect: [200] POST http://localhost:3000/webhooks/stripe
Confirming in production
- The Dashboard's event deliveries for the endpoint went from 100 percent failed to 100 percent
200within a minute of the deploy. - Stripe retries failed deliveries for up to three days in live mode, so most of the 312 failed
events from the four-hour window arrived again on their own. For bookings that customers were
actively asking about, we did not wait for the backoff and resent the events with
stripe events resend <event_id> --webhook-endpoint=<endpoint_id>. - A reconciliation query compared succeeded PaymentIntents from the Stripe API against bookings still pending; it returned zero rows the next morning.
Regression guard
- The signed-payload test above runs in CI on every change to app wiring.
- We added an alert on the ratio of
stripe signature verification failedlog lines to webhook requests. More than 5 percent over 10 minutes pages the on-call engineer; normal background noise from scanners stays well below that. - Code review checklist item for any change to
createApp(): "Are raw-body routes still mounted before body parsers?"
Security implications
The bug failed closed: unsigned or mismatched payloads were rejected, so no fake event could have
been accepted. The tempting wrong fix, catching the error and continuing, would have opened the
endpoint to forged payment confirmations. Keep the default 300-second tolerance (never set it to
0, which disables the recency check), keep the signing secret per endpoint and per mode, and roll
it from the Dashboard if it ever appears in logs. Our handler never logs the raw body or the
signature header.
Prevention checklist
- Mount every signed webhook route with a raw body parser before global JSON middleware.
- Pass the original Buffer or string to the verifier, never a re-serialised object.
- Use the correct secret for each source: Dashboard endpoint secret in deployed environments, the
stripe listensecret locally. - Return 2xx quickly and process events from a queue.
- Alert on verification failure rate, not just on 5xx.
References
- Stripe documentation: Resolve webhook signature verification errors — https://docs.stripe.com/webhooks/signature
- Stripe documentation: Receive Stripe events in your webhook endpoint — https://docs.stripe.com/webhooks
- Stripe documentation: Webhook quickstart — https://docs.stripe.com/webhooks/quickstart
- Express 4.x API reference:
express.raw()andexpress.json()— https://expressjs.com/en/4x/api.html
Add a label
Embed this post
<iframe src="https://hibug.me/stripe-webhook-signature-verification-raw-body" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.