What this usually means
Stripe signs webhook payloads using a HMAC-SHA256 signature based on the raw POST body. The verification fails when the signature computed by your server doesn't match the one Stripe sent. The root cause is almost never a malicious request — it's almost always that your code is hashing a different string than Stripe used. This happens because of JSON body parsers (like express.json()) that modify the raw body, or because the signing secret is incorrect or outdated. Clock drift (time difference > 5 minutes) can also cause failure because the signature includes a timestamp. In rare cases, proxies or load balancers strip or modify the `Stripe-Signature` header.
The first ten minutes — establish facts before touching code.
- 1Check the `Stripe-Signature` header: `curl -I https://your-webhook.com` won't show it — instead, inspect the actual POST request using Stripe's webhook logs or a tool like ngrok with request inspection.
- 2Compare the signing secret: `echo $STRIPE_WEBHOOK_SECRET | head -c 20` — ensure it matches the secret in Stripe Dashboard (whsec_...).
- 3Log the raw body before any parsing: add `console.log(req.body)` at the start of the handler — you need the exact bytes Stripe sent.
- 4Check server time: run `date` and compare to `cat /proc/driver/rtc` or `ntpq -p` — if offset > 300 seconds, fix NTP.
- 5Test with Stripe CLI: `stripe trigger payment_intent.succeeded` and watch the raw payload — this isolates your endpoint from network issues.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchStripe Dashboard → Developers → Webhooks → [Your endpoint] → Recent attempts — shows raw request and error details
- searchYour webhook handler code: where you call `stripe.webhooks.constructEvent()` or `constructEventAsync()`
- searchMiddleware that parses JSON: e.g., `app.use(express.json())` — this consumes the raw body unless configured otherwise
- searchServer environment variables: `STRIPE_WEBHOOK_SECRET` or similar — verify no trailing whitespace or encoding issues
- searchNTP configuration: `/etc/ntp.conf` or `timedatectl status` for clock sync
- searchReverse proxy/load balancer logs: nginx `access.log` or haproxy — check if headers are forwarded
- searchApplication logs: search for `stripe`, `signature`, `webhook` — often the error stack trace is truncated
Practical causes, not theory. These are the things you will actually find.
- warningRaw body consumed by JSON parser: `express.json()` or `body-parser` parses the body and the raw buffer is lost; you need to get the raw body before parsing.
- warningWrong signing secret: a typo, extra whitespace, or using the wrong environment variable (test vs live secret).
- warningClock drift: server time is off by more than 5 minutes, causing the timestamp in the signature to be considered expired.
- warningSignature header missing or malformed: proxy or CDN strips the `Stripe-Signature` header; the header is present but split across multiple lines incorrectly.
- warningPayload encoding mismatch: Stripe sends UTF-8, but your server decodes as ASCII or uses a different charset, altering the raw bytes.
- warningMultiple JSON parsers: if you have multiple middleware that parse the body, the first one consumes it and the second gets an empty string.
Concrete fix directions. Pick the one that matches your root cause.
- buildCapture raw body before any parsing: in Express, use `express.raw({ type: 'application/json' })` or the `stripe-webhook-middleware` package that handles raw body.
- buildExplicitly pass the raw body string: instead of `req.body`, use `req.rawBody` (if you stored it) or read from the request stream manually.
- buildUse Stripe's library method: `stripe.webhooks.constructEvent(payload, sig, secret)` — ensure `payload` is the raw string, not a parsed object.
- buildVerify the signing secret: copy it directly from Stripe Dashboard, use `stripe listen --forward-to localhost:3000/webhook` to test with a known secret.
- buildFix NTP: install `ntp` or `chrony`, restart the service, and verify with `timedatectl` or `ntpq -p`.
- buildFor proxies: configure nginx to pass the raw body with `proxy_pass_request_body on;` and ensure `Stripe-Signature` header is not dropped.
A fix you cannot prove is a guess. Close the loop.
- verifiedUse Stripe CLI to send a test event: `stripe trigger payment_intent.succeeded` and check your handler returns HTTP 200.
- verifiedInspect the raw payload and signature: log `JSON.stringify(req.body)` and `req.headers['stripe-signature']` before verification.
- verifiedManually compute the signature: take the raw body, prepend timestamp, and compute HMAC-SHA256 — compare to the header signature.
- verifiedSimulate clock drift: temporarily set server time off by 6 minutes and confirm failure, then fix time and confirm success.
- verifiedCheck Stripe Dashboard webhook attempts: after fix, you should see green checkmarks and HTTP 200 responses.
Things that make this bug worse or harder to find.
- warningDon't parse the body with JSON middleware before verifying the signature — the raw body must be used.
- warningDon't hardcode the signing secret in code — use environment variables and rotate secrets safely.
- warningDon't ignore the timestamp tolerance — Stripe allows 5 minutes by default; don't disable this check in production.
- warningDon't log the full signing secret — it's a secret; log only the first few characters for debugging.
- warningDon't assume the raw body is available at `req.body` — it's often an object after parsing.
- warningDon't use `JSON.stringify(req.body)` to get the raw body — this may reorder keys, change whitespace, or serialize differently.
Production webhook signature failure after Express upgrade
Timeline
- 14:32PagerDuty alert: Stripe webhook returning 400 for payment_intent.succeeded
- 14:34Checked Stripe Dashboard: 'No signatures found matching the expected signature' for last 10 attempts
- 14:37Logged signing secret: first 10 chars match dashboard (whsec_abc...). Server time shows UTC, within 2 seconds.
- 14:40Added raw body logging: `console.log(req.body)` shows `[object Object]` — raw body already parsed.
- 14:42Checked recent deploy: two days ago, upgraded Express from 4.17 to 4.18 and added `app.use(express.json())`.
- 14:45Found that `express.json()` now consumes raw body by default; previous code relied on a custom raw body parser.
- 14:47Updated webhook handler to use `express.raw({ type: 'application/json' })` and pass `req.body.toString()` to `constructEvent`.
- 14:50Deployed fix, triggered test event — returns HTTP 200. Verified in Dashboard.
The alert came in at 2:32 PM on a Tuesday. Stripe was retrying webhooks for payment_intent.succeeded, and all were failing with signature verification errors. I checked the Stripe Dashboard and saw the dreaded 'No signatures found matching the expected signature'. My first thought was that someone had rotated the secret without telling me. But the secret matched. Server time was fine. So it had to be the raw body.
I added a quick console.log of req.body right at the top of the handler. It logged `[object Object]` — meaning the body was already parsed as JSON. That's when I remembered the Express upgrade. The old code had a manual raw body parser using `body-parser` with `verify` option. The new code used `express.json()` which consumes the stream and parses it, leaving no raw buffer. The stripe library was receiving the parsed object instead of the raw string.
I fixed it by replacing `app.use(express.json())` with `app.use(express.raw({ type: 'application/json' }))` for the webhook route only, then calling `stripe.webhooks.constructEvent(req.body.toString(), sig, secret)`. Deployed and immediately the next webhook succeeded. The lesson: always know what your body parser does to the raw payload, especially after upgrades.
Root cause
Express 4.18's express.json() middleware consumed the raw body stream, leaving no raw buffer for Stripe signature verification.
The fix
Used express.raw({ type: 'application/json' }) on the webhook route and passed req.body.toString() to constructEvent.
The lesson
Always capture the raw body before any JSON parsing when dealing with webhook signatures. Be wary of framework upgrades that change middleware behavior.
Stripe sends a `Stripe-Signature` header that contains one or more signatures, each prefixed with a timestamp (e.g., `t=1492774577,sig=...`). The signature is an HMAC-SHA256 of the raw payload concatenated with the timestamp, using the webhook secret as the key. Your server must recompute this HMAC and compare it to the provided signature(s). The library also checks that the timestamp is within a tolerance (default 5 minutes) to prevent replay attacks.
The key insight: the payload must be exactly the raw request body as received — any transformation (parsing, re-encoding, whitespace changes) will produce a different HMAC. This is why middleware that parses JSON is problematic: it turns the raw bytes into a JavaScript object, and when you JSON.stringify it back, the output may differ (key order, spacing) from the original.
In Express, you have several options to preserve the raw body. The simplest is to use `express.raw({ type: 'application/json' })` on the webhook route, which gives you a Buffer. You can then call `buffer.toString('utf8')` to get the string for the Stripe library. Alternatively, you can use the `body-parser` middleware with a `verify` callback that stores the raw body on `req.rawBody`.
Another approach is to use Stripe's official middleware for Express: `stripe.webhooks.constructEvent` expects the raw body as a string or buffer. The package `stripe-webhook-middleware` can handle this automatically. If you're using a framework like Koa or Fastify, similar raw body parsers exist. The important thing is to avoid any middleware that parses the body before the signature check.
Stripe's timestamp tolerance is 5 minutes by default. If your server clock is off by more than that, verification will fail even if the signature matches. This is common on cloud VMs that don't have NTP configured, or after a server hibernation. To check, run `date` and compare to a trusted time source. On Linux, `ntpq -p` shows NTP peers and offset. Use `timedatectl` to see if NTP is enabled.
To fix, install NTP (e.g., `apt install ntp`) or chrony, and ensure it starts on boot. In containerized environments (Docker), the host's time is usually used, but check that the container doesn't have its own clock. On AWS, EC2 instances should have NTP by default, but it's worth verifying.
The Stripe CLI is invaluable for testing webhooks locally without deploying. Run `stripe listen --forward-to localhost:3000/webhook` to get a local endpoint that forwards Stripe events. The CLI prints the webhook secret you need to use. Then trigger events with `stripe trigger payment_intent.succeeded`. This isolates the issue: if it works locally but fails on production, the problem is likely environment-specific (proxy, secret, time).
When testing locally, enable verbose logging in your app to see the raw payload and signature. You can also manually compute the signature using a tool like `openssl dgst -sha256 -hmac` to verify your understanding. Compare the HMAC of the raw body vs. the parsed body to see the difference.
Frequently asked questions
Why does Stripe webhook signature verification pass in test mode but fail in production?
Most likely you're using different signing secrets for test and live mode. Stripe provides separate webhook secrets for test and live environments. Double-check that your production environment is using the live secret (starts with `whsec_...` from the live mode webhook endpoint). Also, test mode events are sent from Stripe's test infrastructure, which may have different timing or payload characteristics, but the root cause is almost always the secret.
How can I manually verify a Stripe webhook signature for debugging?
You can compute the expected signature using the command line. Assume the raw payload is in `payload.txt`, the timestamp is `t=1492774577`, and the secret is `whsec_abc`. First, extract the timestamp and signature from the header. Then compute: `echo -n "1492774577.$(cat payload.txt)" | openssl dgst -sha256 -hmac "whsec_abc"`. The output should match the signature in the header. If it doesn't, the payload or secret is wrong.
What if the Stripe-Signature header is missing entirely?
This usually means your server is not receiving the header due to a proxy or load balancer stripping it. Check your reverse proxy configuration (nginx, haproxy) to ensure it forwards all headers. Also, if you're using API Gateway or CloudFront, they may strip unknown headers. In Stripe Dashboard, you can inspect the request that Stripe sent — if the header is present there but not in your logs, it's a proxy issue.
Can I disable signature verification for testing?
Technically yes, but you should never do that in production. For local development, you can bypass verification by not calling `constructEvent` and just parsing the body directly. But this is dangerous because it opens you up to forged events. Instead, use Stripe CLI to send real signed events to your local server. If you must test without verification, at least validate the event ID and secret in a different way.
Why does my webhook work with Stripe CLI but not with actual Stripe events?
Stripe CLI sends events from your local machine, so the network path is different. The most common reason is that your production server is behind a load balancer that modifies the request body (e.g., stripping whitespace, re-encoding). Also, Stripe's actual events may have different payload sizes or encoding. Check if your production server has any middleware that transforms the body, like gzip decompression or charset conversion.