Verify an HMAC Webhook Signature in Express with the Raw Body - NextGenBeing
Back to discoveries

Verify an HMAC Webhook Signature in Express with the Raw Body

Verify an HMAC-SHA256 webhook signature in Express with express.raw and timingSafeEqual, with real output for five cases including the express.json bug.

Web Development 6 min read
Bekzod Erkinov

Bekzod Erkinov

Oct 19, 2026 • 1 views
Size:
Height:
6 min read 2,603 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
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-8 still matches. The type option accepted it, so you do not need to list charset variants.
  • A different content type leaves req.body undefined. With text/plain, express.raw skipped the request, as the Express docs describe. Without the Buffer.isBuffer check, createHmac(...).update(undefined) would throw and you would see a 500 instead of a clean 401. Test with Buffer.isBuffer, because typeof reports object for 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 with Buffer.from stopping 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

  1. Mount the webhook route before any global body parser (rows 5 and 6), or capture the buffer with the verify option and check before reading req.body.
  2. Use express.raw({ type }) with your sender's content type, check Buffer.isBuffer(req.body), and hash req.body directly.
  3. Decode the header with Buffer.from(header, 'hex'), compare lengths, return 401 on a mismatch, then call crypto.timingSafeEqual (row 4).
  4. Replay a captured body with one byte edited, as in row 2, against your deployed endpoint and confirm it answers 401.
Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

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

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.