Blog · · 6 min read
Why Webhook Signature Verification Fails: Raw Body, Wrong Secret, Clock Skew, Proxies
Why webhook signature checks fail (raw body, wrong secret, hex vs base64, clock skew, proxies), how to debug it, and what Stripe, Shopify, GitHub and others sign.
Key points
- Most signature failures happen because the HMAC is computed over a parsed-and-reserialized body. Compute it over the exact bytes you received.
- The next most common causes are the wrong secret (test vs live, CLI vs dashboard), hex vs base64 mix-ups, clock skew, and proxies that change the URL.
- Providers sign different things (body only, timestamp plus body, URL plus params) and encode differently, so check the table before you write the check.
When webhook signature verification fails, check one thing first: are you computing the HMAC over the exact bytes that arrived? If your framework parsed the body into JSON and you turned it back into a string, the content is the same but the bytes are not, and the signature will never match. This post lists the causes in the order you are likely to hit them, gives a step-by-step way to find where your calculation diverges, and compares how the major providers sign their webhooks.
The usual causes, most common first
- The body was parsed before you verified it.
express.json(), the Next.js Pages Router body parser, or a FastAPI handler that takes a Pydantic model all hand you a parsed object. Serializing it again changes whitespace, key order or escaping. - Wrong secret. Test vs live, one endpoint's secret vs another's, or the temporary secret a CLI printed. Stripe's own troubleshooting page says the most common error is using the wrong endpoint secret (Stripe).
- Wrong encoding. You output hex where the provider sends base64, or the reverse. Some schemes also base64-encode the secret itself (Standard Webhooks'
whsec_…), and you must decode it before use. - Clock skew. Schemes that sign a timestamp reject requests outside a tolerance window, typically five minutes. A server with a drifting clock fails intermittently.
- The URL changed. Twilio signs the URL. A proxy that terminates TLS can turn
httpsintohttpor add a port, and the signature no longer matches. - Right after a secret rotation. The sender switched to a new secret and the receiver still only checks the old one.
Find the mismatch step by step
Guessing wastes time. Capture what arrived and redo the math.
- Log the raw body and headers. Before verification, write the received bytes somewhere. Base64-encode the body when you log it so line endings and encodings survive.
- Confirm the secret. Print the first and last few characters of the secret your code loaded and compare them with the provider's dashboard. Look for stray whitespace or a trailing newline from the environment file.
- Recompute locally. Using the logged body and the secret, compute the HMAC exactly as the provider documents. If it matches the header, your handler is modifying the body.
- If it doesn't match, recheck what is signed. Is there a timestamp or an ID in front of the body? Is the URL part of it? Which separator? Use the table below and the provider's docs.
- Check the clock and the tolerance. Make sure the server syncs with NTP and that you haven't set a tolerance of zero.
For step 3, a GitHub-style check looks like this:
import { createHmac } from 'node:crypto';
import { readFileSync } from 'node:fs';
const raw = Buffer.from(readFileSync('body.b64', 'utf8'), 'base64'); // the logged raw body
const expected = 'sha256=' + createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!).update(raw).digest('hex');
console.log(expected); // compare with the X-Hub-Signature-256 you receivedGitHub publishes a test vector you can use to check your function: with the secret It's a Secret to Everybody and the payload Hello, World!, the signature is sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 (GitHub).
What each provider signs
Checked against each provider's official documentation on 2026-09-27.
| Provider | Header | Signed content | Algorithm and encoding | Secret |
|---|---|---|---|---|
| Stripe | Stripe-Signature (t=…,v1=…) |
timestamp.body |
HMAC-SHA256, hex | Per-endpoint whsec_… |
| Shopify | X-Shopify-Hmac-Sha256 |
body | HMAC-SHA256, base64 | The app's client secret |
| GitHub | X-Hub-Signature-256 (sha256=…) |
body | HMAC-SHA256, hex | The webhook's secret |
| Slack | X-Slack-Signature (v0=…) |
v0:timestamp:body |
HMAC-SHA256, hex | The app's signing secret |
| LINE | x-line-signature |
body | HMAC-SHA256, base64 | Channel secret |
| Twilio | X-Twilio-Signature |
URL plus sorted POST params | HMAC-SHA1, base64 | Auth token |
| Standard Webhooks | webhook-signature (v1,…) |
id.timestamp.body |
HMAC-SHA256, base64 | Base64-decoded part after whsec_ |
Sources: Stripe, Shopify, GitHub, Slack, LINE, Twilio, Standard Webhooks spec
Three things in that table trip people up:
- Stripe and Standard Webhooks secrets both start with
whsec_but are used differently. Stripe's libraries take the string as is. Standard Webhooks strips the prefix and base64-decodes the rest to get the key bytes. - Shopify uses the app's client secret, not a per-webhook secret.
- Twilio signs the URL and parameters, not the body. For JSON requests it adds a
bodySHA256query parameter with the hash of the raw body, and that becomes part of the signed URL. Twilio's docs tell you to use the exact URL configured in Twilio, including any URL-encoded characters.
Receive the body unparsed
Fix cause number one in the framework: read the webhook route's body as bytes or a string before any JSON parser runs. In Express that means express.raw({ type: 'application/json' }) on the webhook route, registered before app.use(express.json()); in the Next.js App Router, await request.text(). The raw body guide has working code for eight frameworks.
If you work with the body as a string, keep it UTF-8. GitHub's docs explicitly say to handle the payload as UTF-8, because payloads can contain Unicode characters. Decoding as Latin-1 and re-encoding changes any non-ASCII byte.
Compare in constant time
Never compare signatures with ===. A normal string comparison stops at the first differing character, which leaks timing information. Use crypto.timingSafeEqual in Node, hmac.compare_digest in Python, or crypto.subtle.verify with Web Crypto.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyGithub(raw: Buffer, header: string): boolean {
const expected = Buffer.from(
'sha256=' + createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!).update(raw).digest('hex'),
);
const got = Buffer.from(header);
// timingSafeEqual throws on different lengths, so check first
return got.length === expected.length && timingSafeEqual(got, expected);
}Timestamp tolerance and rotation
Stripe's libraries and Slack both use a five-minute tolerance. If "timestamp too old" errors come and go, fix the server's clock (NTP) instead of widening the window; the window exists to stop replayed requests. Webhook replay attacks covers how to choose it.
If failures start right after a rotation, check how the provider signs during the overlap. Stripe can keep the old secret active for up to 24 hours and sends one signature per active secret. Standard Webhooks puts several space-separated signatures in webhook-signature; any one matching is enough. A receiver that only reads the first signature breaks during that window. See rotating signing secrets for the receiver-side steps.
Verifying webhooks from Webhook Admin
Webhook Admin signs every message the Standard Webhooks way: webhook-id, webhook-timestamp and webhook-signature headers, with an HMAC-SHA256 over id.timestamp.body, base64-encoded after v1,. On the receiving side, pass the raw body and headers to Webhook.verify from the webhookadmin npm package:
import express from 'express';
import { Webhook } from 'webhookadmin';
const wh = new Webhook(process.env.WEBHOOK_SECRET!); // the endpoint's whsec_…
const app = express();
app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const event = await wh.verify(req.body, req.headers); // { type, timestamp, data }
res.sendStatus(204);
} catch {
res.sendStatus(400);
}
});If you pass a parsed object, verify throws an error that says it needs the raw request body. The default tolerance is five minutes either way, and during a rotation (24 hours) a signature from either secret verifies. The signing scheme is documented in signature verification, and the libraries for other languages are listed under libraries.
If you send webhooks yourself and want signing, retries and delivery logs without building them, Webhook Admin is free up to 50,000 messages a month: https://app.webhookadmin.com/signup
FAQ
Why does verification pass locally but fail in production?
Usually one of two things. You are using a different secret in production (a test secret, or the one the Stripe CLI printed), or something that only exists in production, such as a proxy or load balancer, is changing the body or the URL. Log the raw body and headers in production and replay the calculation locally to see which.
Does reordering JSON keys or changing whitespace change the signature?
Yes. The signature is computed over bytes. Two JSON documents that mean the same thing but differ in whitespace, key order, or number and Unicode formatting produce different signatures, so a body you parsed and stringified again will not verify.
Should I compare the signature as hex or base64?
It depends on the provider. Stripe, GitHub and Slack use hex; Shopify, LINE, Twilio and Standard Webhooks use base64. Encode your computed HMAC the same way, then compare in constant time.