Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · November 21, 2024 · 9 min read · 46 Views
Banner: Stripe webhook fails with no signatures found matching the expected signature for payload

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, and chronyc tracking on 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

  1. Keep the global parser and capture the raw bytes. express.json() accepts a verify callback 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.

  2. 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 200 within 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 failed log 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 listen secret locally.
  • Return 2xx quickly and process events from a queue.
  • Alert on verification failure rate, not just on 5xx.

References

Add a label

#nodejs #stripe #express #webhooks #hmac
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.