Bekzod Erkinov
Listen to Article
Loading...Table of contents · 9 sections
Verify an HMAC Webhook Signature in Express with the Raw Body
If your Express webhook handler hashes JSON.stringify(req.body), a correctly signed request can still come back 401. The sender signed the bytes it sent, and JSON.stringify rebuilds different bytes. This post shows a receiver that hashes the raw body with express.raw(), and prints the output of the five cases that matter: a valid request, one changed byte, a wrong secret, a wrong-length signature, and express.json() mounted first.
Everything ran on Node v24.19.0 with Express 5.3.0 (body-parser 2.3.0) on 2026-10-11. The sender is a fetch call in the same script, so nothing here was tested against a real vendor's webhooks. The signature header must be bare hex for this script.
Why you need the raw body for HMAC verification
An HMAC is computed over bytes, so the receiver has to hash the same bytes the sender hashed. According to the Express API docs, express.raw() parses bodies into a Buffer, only for requests whose Content-Type matches its type option (default application/octet-stream). The code below passes type: 'application/json'.
The whole script (webhook.js)
One file: the receiver, the buggy variant, and the driver that sends every case.
const express = require('express');
const crypto = require('node:crypto');
const SECRET = 'whsec_test';
const sign = (body, s = SECRET) => crypto.createHmac('sha256', s).update(body).digest('hex');
// Receiver: express.raw on the route, so req.body is the exact bytes.
function makeApp(secret, { lengthGuard = true, jsonFirst = false } = {}) {
const app = express();
if (jsonFirst) app.use(express.json()); // global parser mounted before the route
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', secret).update(req.body).digest();
const got = Buffer.from(req.get('x-signature') || '', 'hex');
if (lengthGuard && got.length !== expected.length)
return res.status(401).json({ ok: false, why: 'bad length' });
if (!crypto.timingSafeEqual(got, expected))
return res.status(401).json({ ok: false, why: 'mismatch' });
res.json({ ok: true });
});
// report a thrown error as JSON so the run shows its code
app.use((err, req, res, next) => res.status(500).json({ ok: false, code: err.code }));
return app;
}
// The bug: express.json() parses first, then the handler hashes JSON.stringify(req.body).
function makeBuggyApp(secret) {
const app = express();
app.use(express.json());
app.post('/webhook', (req, res) => {
const expected = crypto.createHmac('sha256', secret).update(JSON.stringify(req.body)).digest();
const got = Buffer.from(req.get('x-signature') || '', 'hex');
if (got.length !== expected.length) return res.status(401).json({ ok: false, why: 'bad length' });
if (!crypto.timingSafeEqual(got, expected)) return res.status(401).json({ ok: false, why: 'mismatch' });
res.json({ ok: true });
});
return app;
}
async function post(server, body, sig) {
const r = await fetch(`http://127.0.0.1:${server.address().port}/webhook`, { method: 'POST',
headers: { 'content-type': 'application/json', 'x-signature': sig }, body });
return `${r.status} ${await r.text()}`;
}
(async () => {
const good = makeApp(SECRET).listen(0);
const unguarded = makeApp(SECRET, { lengthGuard: false }).listen(0);
const bug = makeBuggyApp(SECRET).listen(0);
const ordered = makeApp(SECRET, { jsonFirst: true }).listen(0);
const body = '{"id": 1, "event":"paid", "amount":500}';
const sig = sign(body);
const row = (label, text) => console.log(label.padEnd(28), text);
console.log('signature:', sig);
row('1 valid', await post(good, body, sig));
row('2 one byte changed', await post(good, body.replace('500', '501'), sig));
row('3 wrong secret', await post(good, body, sign(body, 'other')));
row('4 short sig, guarded', await post(good, body, sig.slice(0, 20)));
row('4b short sig, no guard', await post(unguarded, body, sig.slice(0, 20)));
row('5 express.json first', await post(bug, body, sig));
console.log('re-serialised:', JSON.stringify(JSON.parse(body)));
console.log('original :', body);
row('6 raw() after json()', await post(ordered, body, sig));
good.close(); unguarded.close(); bug.close(); ordered.close();
})();
Run it:
mkdir hmac-demo && cd hmac-demo
npm init -y && npm i express
# save the script above as webhook.js, then:
node webhook.js
Output
Diff your stdout against this:
signature: 59bc2b4383f7c910e27e0ca7d00b578b56cec0e24b7b580bbc5e0bfe64408857
1 valid 200 {"ok":true}
2 one byte changed 401 {"ok":false,"why":"mismatch"}
3 wrong secret 401 {"ok":false,"why":"mismatch"}
4 short sig, guarded 401 {"ok":false,"why":"bad length"}
4b short sig, no guard 500 {"ok":false,"code":"ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH"}
5 express.json first 401 {"ok":false,"why":"mismatch"}
re-serialised: {"id":1,"event":"paid","amount":500}
original : {"id": 1, "event":"paid", "amount":500}
6 raw() after json() 500 {"ok":false,"code":"ERR_INVALID_ARG_TYPE"}
What each case shows
Rows 1 to 3. Row 1 returns 200: same bytes, same secret. Row 2 changes 500 to 501 and keeps the old signature, so the digests differ and the answer is 401 mismatch. Row 3 leaves the body alone but signs with the secret other, with the same 401.
Row 4, the length guard. The Node crypto docs say crypto.timingSafeEqual needs both inputs to have the same byte length and throws otherwise. The docs also describe it as suitable for comparing HMAC digests, with a caveat that it does not make the surrounding code timing-safe. The signature header comes from the caller, so the caller picks its length. In row 4 the header holds the first 20 hex characters, and the guard answers 401 bad length before the comparison runs. Row 4b removes the guard (lengthGuard: false): timingSafeEqual throws and the error middleware reports a 500 with ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH. Related: Node crypto basics.
Why express.json() breaks HMAC webhook verification
Row 5 uses the same body and signature as row 1, yet the result is 401 mismatch. The last two printed lines are the reason. The sender signed {"id": 1, "event":"paid", "amount":500}, with a space after the first colon and two after the comma. After express.json() parsed it, JSON.stringify produced {"id":1,"event":"paid","amount":500}. The data is the same and the bytes are not, so the HMACs differ.
Row 6 puts express.raw on the route but leaves a global express.json() mounted before it. The handler threw ERR_INVALID_ARG_TYPE, so req.body was not a Buffer. The installed node_modules/body-parser/lib/read.js (version 2.3.0) starts its read function with:
if (onFinished.isFinished(req)) {
debug('body already parsed')
next()
return
}
Once express.json() has consumed the request stream, express.raw() hits this early return, calls next(), and leaves req.body as the object the first parser set. Then createHmac(...).update(req.body) gets a plain object and throws. This is a reading of version 2.3.0 only, not a documented contract. Re-run row 6 after you upgrade.
Fix: register the webhook route before express.json()
Order matters, so the webhook route goes first (more in Express middleware order). fixed.js registers the raw route, then app.use(express.json()), then another route:
const express = require('express');
const crypto = require('node:crypto');
const SECRET = 'whsec_test';
const app = express();
// webhook route first: express.raw sees the request before express.json
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest();
const got = Buffer.from(req.get('x-signature') || '', 'hex');
if (got.length !== expected.length || !crypto.timingSafeEqual(got, expected))
return res.status(401).json({ ok: false });
res.json({ ok: true });
});
app.use(express.json());
app.post('/other', (req, res) => res.json({ parsed: req.body }));
async function post(server, path, body, headers) {
const r = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { method: 'POST', headers, body });
return `${r.status} ${await r.text()}`;
}
(async () => {
const server = app.listen(0);
const body = '{"id": 1, "event":"paid", "amount":500}';
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
const json = { 'content-type': 'application/json' };
const row = (l, t) => console.log(l.padEnd(26), t);
row('/webhook valid', await post(server, '/webhook', body, { ...json, 'x-signature': sig }));
row('/webhook 1 byte changed', await post(server, '/webhook', body.replace('500', '501'), { ...json, 'x-signature': sig }));
row('/other (after json)', await post(server, '/other', '{"x": 1}', json));
server.close();
})();
Output:
/webhook valid 200 {"ok":true}
/webhook 1 byte changed 401 {"ok":false}
/other (after json) 200 {"parsed":{"x":1}}
The webhook route gets a Buffer and verifies, a changed byte gets 401, and /other, registered after the parser, still receives a parsed object.
When express.json() must stay global: the verify callback
Reordering is not always possible, for example when a framework mounts express.json() for you. The Express API docs list a verify option on express.json(), a function called with (req, res, buf, encoding) before parsing. I used it to stash the buffer on the request, then ran the same HMAC check on req.rawBody (extra.js):
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
app.post('/verify-cb', (req, res) => {
const r = check(req.rawBody, req.get('x-signature')); // same Buffer + timingSafeEqual check as before
res.status(r.ok ? 200 : 401).json({ ...r, parsedAmount: req.body.amount });
});
A verify cb valid 200 {"ok":true,"parsedAmount":500}
A verify cb 1 byte changed 401 {"ok":false,"why":"mismatch","parsedAmount":501}
Both bodies were parsed into objects, and only the signature check decided the status. Look at the second line: the handler could read amount: 501 from req.body on a request that failed verification. With this approach parsing happens first, so never act on req.body fields before the check passes. It also only captures bodies that express.json() itself parses, so other content types need their own parser with a verify.
Header and content-type pitfalls
Four more cases from extra.js, using express.raw({ type: 'application/json' }) on the route:
B json+charset valid 200 {"ok":true,"bodyType":"object","isBuffer":true}
B text/plain content-type 401 {"ok":false,"why":"no raw body","bodyType":"undefined","isBuffer":false}
B uppercase hex sig 200 {"ok":true,"bodyType":"object","isBuffer":true}
B "sha256=" prefixed sig 401 {"ok":false,"why":"mismatch","bodyType":"object","isBuffer":true}
application/json; charset=utf-8still matches. Thetypeoption accepted it, so you do not need to list charset variants.- A different content type leaves
req.bodyundefined. Withtext/plain,express.rawskipped the request, as the Express docs describe. Without theBuffer.isBuffercheck,createHmac(...).update(undefined)would throw and you would see a 500 instead of a clean 401. Test withBuffer.isBuffer, becausetypeofreportsobjectfor a Buffer. - Uppercase hex is fine.
Buffer.from(header, 'hex')decoded the uppercased digest to the same bytes, so no normalising was needed. - A
sha256=prefix is not stripped. I did not print the decoded length, but the result is consistent withBuffer.fromstopping at the first non-hex character: the check failed. If your sender prefixes the digest, remove the prefix before decoding. This script assumes bare hex.
Steps for your handler
- Mount the webhook route before any global body parser (rows 5 and 6), or capture the buffer with the
verifyoption and check before readingreq.body. - Use
express.raw({ type })with your sender's content type, checkBuffer.isBuffer(req.body), and hashreq.bodydirectly. - Decode the header with
Buffer.from(header, 'hex'), compare lengths, return 401 on a mismatch, then callcrypto.timingSafeEqual(row 4). - Replay a captured body with one byte edited, as in row 2, against your deployed endpoint and confirm it answers 401.
Keep reading
Production-Ready Authentication: A Deep Dive into JWT, OAuth2, and Session Security
21 min · 1,211 views
Artificial IntelligenceAdvanced Security Measures for Protecting Against Sophisticated Threats
18 min · 1,166 views
Mobile DevelopmentComplete Solution: Scaling a Node.js Application with Kubernetes and Docker
29 min · 915 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In