Webhook signature verification failed
Count the bytes before blaming the secret. Post one signed body to a route that hashes JSON.stringify(req.body) and to a route that hashes the raw buffer: the first hashes 62 bytes and answers 401, the second hashes the 72 bytes that arrived and answers 200. The secret is identical in both.
Why check this
Run this the moment a verifier starts rejecting deliveries that the sender's dashboard reports as sent, and run it once in advance on any endpoint sitting behind a body parser. The failure is expensive because it hides: a parsed body round-trips unchanged through JSON.parse and JSON.stringify when it is compact, which is what a test fixture usually is, so the verifier passes its own tests and rejects live events. Paid orders stop reaching the queue and nothing in the logs points at the parser. The byte counts in this procedure separate a body problem from a secret problem in one request.
Prerequisites
- Node 22 and express 5, installed with
npm i express. The receiver below carries three routes, one per way of getting at the body. - openssl 3, or any tool that hashes bytes.
- curl, for the three deliveries. Send the body with
--data-binary, which transmits the file unchanged. - A body saved as
pretty.json, 72 bytes, written the way a sender that formats its JSON would write it:{"id": "evt_9", "amount": 2599.00, "currency": "usd", "livemode": false}. - The second script,
reserialise.mjs, and itsbodies.jsoninput, which hold four bodies to push through the parser. - Port 8924 free. Confirm with
netstat -ano | grep 8924, and stop the receiver afterwards.
// express-recv.js - one signature check, three ways of getting at the body.
const express = require('express');
const crypto = require('node:crypto');
const SECRET = 'whsec_local_test';
const app = express();
const hmac = (buf) => crypto.createHmac('sha256', SECRET).update(buf).digest('hex');
const answer = (res, route, signed, sent) => {
const want = hmac(signed);
const ok = sent.length === want.length && crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(want));
console.log(`${route} signed ${signed.length} bytes want=${want.slice(0, 16)}… sent=${sent.slice(0, 16)}… ${ok ? 'OK' : 'MISMATCH'}`);
res.sendStatus(ok ? 200 : 401);
};
// Wrong: express.json() replaced the bytes with an object, so this signs a re-serialisation.
app.post('/parsed', express.json(), (req, res) =>
answer(res, '/parsed ', Buffer.from(JSON.stringify(req.body), 'utf8'), String(req.get('x-signature') || '')));
// Right, and req.body stays a Buffer, so the route has to parse it itself.
app.post('/raw', express.raw({ type: '*/*' }), (req, res) =>
answer(res, '/raw ', req.body, String(req.get('x-signature') || '')));
// Right, and the route still gets a parsed object: the verify hook sees the bytes first.
app.post('/verify', express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }), (req, res) =>
answer(res, '/verify ', req.rawBody, String(req.get('x-signature') || '')));
app.listen(8924, '127.0.0.1', () => console.log('express 5.2.1 on http://127.0.0.1:8924'));
// reserialise.mjs - what a body parser does to the bytes a signature covers.
import { createHmac } from 'node:crypto';
import { readFileSync } from 'node:fs';
const cases = JSON.parse(readFileSync('bodies.json', 'utf8'));
const mac = (s) => createHmac('sha256', 'whsec_local_test').update(Buffer.from(s, 'utf8')).digest('hex').slice(0, 16);
for (const [label, raw] of cases) {
const round = JSON.stringify(JSON.parse(raw));
console.log(label);
console.log(` on the wire ${Buffer.byteLength(raw)} bytes ${mac(raw)}… ${raw}`);
console.log(` re-serialised ${Buffer.byteLength(round)} bytes ${mac(round)}… ${round}`);
console.log(` same bytes: ${raw === round}`);
}
[
["spaces after the colons", "{\"id\": \"evt_9\", \"amount\": 2599.00, \"currency\": \"usd\", \"livemode\": false}"],
["a trailing newline the sender's shell added", "{\"id\":\"evt_9\",\"amount\":2599}\n"],
["keys that look like integers", "{\"2\":\"second\",\"1\":\"first\"}"],
["already compact, which is what your fixture is", "{\"id\":\"evt_9\",\"amount\":2599}"]
]
Steps
- Step 1.
Start the receiver in its own terminal.
node express-recv.js | tee express.logexpress 5.2.1 on http://127.0.0.1:8924 - Step 2.
Sign the file. Reading from the file rather than from a shell variable keeps the bytes intact.
SIG=$(openssl dgst -sha256 -hmac 'whsec_local_test' -r < pretty.json | cut -d' ' -f1) echo "$SIG"4e880d867584bedd953b6ecd534ce56a3ec770aa7bd6369fb7428a11bba90471 - Step 3.
Deliver it to the route that hashes the parsed body, which is what most verifiers do by accident.
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://127.0.0.1:8924/parsed' -H 'content-type: application/json' -H "x-signature: $SIG" --data-binary @pretty.json401 - Step 4.
Send the same bytes to the route that hashes the raw buffer.
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://127.0.0.1:8924/raw' -H 'content-type: application/json' -H "x-signature: $SIG" --data-binary @pretty.json200 - Step 5.
Send them to the route that keeps the raw buffer and still parses the body, which is the fix when a handler needs the object.
curl -s -o /dev/null -w '%{http_code}\n' -X POST 'http://127.0.0.1:8924/verify' -H 'content-type: application/json' -H "x-signature: $SIG" --data-binary @pretty.json200 - Step 6.
Read the receiver log. One number carries the finding.
cat express.logexpress 5.2.1 on http://127.0.0.1:8924 /parsed signed 62 bytes want=4f2a86f12699875d… sent=4e880d867584bedd… MISMATCH /raw signed 72 bytes want=4e880d867584bedd… sent=4e880d867584bedd… OK /verify signed 72 bytes want=4e880d867584bedd… sent=4e880d867584bedd… OKThree requests, one body, one secret. The failing route hashed 62 bytes where 72 arrived, so ten bytes were removed before the HMAC ran. That is the whole bug, and a verifier that reports only a boolean never shows it.
- Step 7.
Push four bodies through a parse and a re-serialisation to see which survive.
node reserialise.mjsspaces after the colons on the wire 72 bytes 4e880d867584bedd… {"id": "evt_9", "amount": 2599.00, "currency": "usd", "livemode": false} re-serialised 62 bytes 4f2a86f12699875d… {"id":"evt_9","amount":2599,"currency":"usd","livemode":false} same bytes: false a trailing newline the sender's shell added on the wire 29 bytes f629b77cf42ec0cd… {"id":"evt_9","amount":2599} re-serialised 28 bytes bcdf64c02f78bb49… {"id":"evt_9","amount":2599} same bytes: false keys that look like integers on the wire 26 bytes fb51edb7b81328d0… {"2":"second","1":"first"} re-serialised 26 bytes 13ca15e2829b7af2… {"1":"first","2":"second"} same bytes: false already compact, which is what your fixture is on the wire 28 bytes bcdf64c02f78bb49… {"id":"evt_9","amount":2599} re-serialised 28 bytes bcdf64c02f78bb49… {"id":"evt_9","amount":2599} same bytes: trueRead the four cases in order. The first loses the spaces and turns
2599.00into2599, ten bytes gone. The second loses one newline the shell added. The third keeps all 26 bytes and reorders them, because a JavaScript object puts integer-like keys first whatever order they arrived in, so a byte count that matches still proves nothing. The fourth is unchanged, and that is the case your fixture is written in, which is why the test suite is green while production is not.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| signed is smaller than the bytes that arrived | A parser removed whitespace before the HMAC | Hash the raw buffer. Use express.raw, or the verify hook when the handler needs the object. |
| signed equals the bytes and it still fails | The bytes are right and something else is not | Compare the secret, the encoding and the signed string. The Stripe scheme prefixes a timestamp. |
| signed is one byte more than the file | A trailing newline was added | Send with --data-binary, and write the file without a final newline. |
| Equal byte counts, different digests | The content changed without changing length | Key reordering by a parser, or a tampered field of the same width. Log the first 16 characters of both digests. |
| The fixture passes and live events fail | The fixture is compact and the sender is not | Add a formatted body to the test set, as case one in step 7. |
| 200 on the parsed route | The sender also emits compact JSON | It works by coincidence. One escaped character from the sender ends it. |
Common mistakes
What to check next
- How to check webhook signature: the base procedure, with a receiver that prints both digests.
- Stripe webhook signature verification: the other way a correct HMAC covers the wrong bytes.
- Github webhook signature verification: a published test vector that tells code errors from configuration errors.
- Webhook replay attack: the check that runs after the signature verifies.
- How to test webhook delivery: the retries that follow every 401 you return.
FAQ
Why does Stripe report an invalid signature when my secret is right?
The verifier is hashing bytes the sender did not hash. Two causes account for most of it: a body parser that re-serialised the JSON, and a signed string that omits the timestamp prefix. Step 6 tells them apart by byte count.
How do I get the raw body in express?
express.raw({ type: '*/*' }) on the webhook route, or express.json({ verify: (req, res, buf) => { req.rawBody = buf } }) when the handler also needs the parsed object. Steps 4 and 5 exercise both.
Can I re-serialise the body in a canonical form instead?
No. The sender hashed the bytes it transmitted, and no canonical form reproduces them in general. Key order, number formatting and escaping are all free choices for the sender.
Does content-length prove the body arrived intact?
It shows the length the sender declared, which catches truncation and misses reordering. Step 7 has a case with equal lengths and different content. The digest is the check, the length is the diagnosis.
Verified
Verified by Maks Vernycurl 8.21.0openssl 3.1.1node 22.23.2express 5.2.1
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: webhook-signature compute and compare an HMAC-SHA256 signature for a payload and secret (Stripe, GitHub, generic formats) with WebCrypto
- Payment gateway testing checklist
- All payments and webhooks checks
intermediate8 minpublished updated Maks Verny