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

// 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

  1. Step 1.

    Start the receiver in its own terminal.

    node express-recv.js | tee express.log
    
    express 5.2.1 on http://127.0.0.1:8924
  2. 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
  3. 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.json
    
    401
  4. 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.json
    
    200
  5. 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.json
    
    200
  6. Step 6.

    Read the receiver log. One number carries the finding.

    cat express.log
    
    express 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… OK

    Three 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.

  7. Step 7.

    Push four bodies through a parse and a re-serialisation to see which survive.

    node reserialise.mjs
    
    spaces 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: true

    Read the four cases in order. The first loses the spaces and turns 2599.00 into 2599, 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

Sign: Verification passes in tests and fails against every live delivery.Cause: The fixture is compact JSON, which survives JSON.parse and JSON.stringify byte for byte, as case four in step 7 shows. Live senders format differently. Put a body with spaces and a decimal amount into the test set and the test fails the same way production does.
Sign: The byte counts match and the digests still differ.Cause: A JavaScript object orders integer-like keys numerically before every string key, whatever order they arrived in. Step 7 shows 26 bytes in and 26 bytes out with an unrelated digest. Key order is not preserved by a parser, so a length check is not a substitute for hashing the raw bytes.
Sign: Moving the verifier behind a new gateway broke it, and nothing in the application changed.Cause: Something in the path rewrote the body: a proxy that re-encodes JSON, a WAF that normalises it, or a framework that decompresses and re-compresses. Log the byte count the verifier sees and compare it with the content-length the sender reported.
Sign: The raw body is empty inside the verifier.Cause: A body parser earlier in the middleware chain already consumed the stream, so the raw handler reads nothing and hashes zero bytes. Register the raw or verify middleware on the webhook route before any global JSON parser, and check that the signed byte count is not zero.

What to check next

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.

intermediate8 minpublished updated Maks Verny