Deemwar Monads & Abstractions

Check the HMAC on the bytes you received

Source: (Post-Quantum) Cryptographic Right Answers, by Filippo Valsorda

A webhook HMAC check can reject a request the sender really signed, and pass data the sender never signed. Both come from checking one copy of the message and acting on another. The fix is to authenticate the exact bytes that arrived, then parse only those.

HMAC-SHA-256 the symmetric answer
5 Right Answers lists, 2009–2026
2 failures, 1 rule

The rule

“Sidestep canonicalization and parser alignment bugs by always authenticating a verbatim string of bytes, not something you need to recompute.”

Filippo Valsorda, “(Post-Quantum) Cryptographic Right Answers”, 12 Oct 2026, HMAC section.

The same post names HMAC-SHA-256 as the answer for symmetric “signatures” and adds: compare tags with dedicated constant-time functions.

Two ways the rebuilt copy fails

Genuine request rejectedThe handler parses the JSON, re-serialises it and hashes the result. The sender signed {"foo": 123}; the handler hashes {"foo":123}. One space or a different key order changes the bytes, so the tag no longer matches.
Unsigned data acceptedSorting keys does not save you. With a duplicate key, one parser keeps the first value and another keeps the last: the checker reads "amount":1, JSON.parse reads "amount":100.

The duplicate-key case is an illustration from general JSON parser behaviour, not an example from the source post.

Good and bad shape

The source post shows both. In the bad shape the tag sits next to the fields it covers, so the receiver must rebuild the signed bytes:

{
    "foo": 123,
    "hmac": "MbPsZq7QGCllJgDnSSy/cyM10HjqiVu35UO7yyY5d3w="
}

In the good shape the signed data travels as one string. The receiver checks the tag over that string, then processes, stores or forwards just {"foo":123}:

{
    "inner": "{\"foo\":123}",
    "hmac": "MbPsZq7QGCllJgDnSSy/cyM10HjqiVu35UO7yyY5d3w="
}

The check, in order

  1. 1

    Keep the raw bytes

    receiver

    Read the body (or the inner string) as bytes. Do not parse it yet.

  2. 2

    Compute HMAC-SHA-256

    receiver, shared key

    Hash those exact bytes with the shared key.

  3. 3

    Compare in constant time

    crypto.timingSafeEqual

    Check the lengths match first, then compare the tags with a constant-time function.

  4. mismatch → reject the request · nothing is parsed
  5. 4

    Parse only those bytes

    JSON.parse

    Parse the same bytes you checked, and act only on what they contain.

One copy of the message: checked, then parsed, then used.

In Express

Hash the raw buffer from express.raw, never JSON.stringify(req.body). crypto.timingSafeEqual throws when the buffers differ in length, so check the length first.

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sent = Buffer.from(req.get('X-Signature') || '', 'base64');
  const mac = crypto.createHmac('sha256', process.env.WEBHOOK_KEY)
    .update(req.body)            // the Buffer as received
    .digest();
  if (sent.length !== mac.length || !crypto.timingSafeEqual(sent, mac)) {
    return res.sendStatus(401);
  }
  const data = JSON.parse(req.body.toString('utf8'));  // parse what you checked
  // ... act on data
  res.sendStatus(204);
});

Header name and key variable are placeholders; use your sender’s documented header and encoding.

What goes wrong, and the rule now

MistakeEffectRule now
Re-serialise JSON, then hashSpacing or key order differs; genuine requests failHash the bytes received
Check one parse, use anotherDuplicate keys let unsigned values throughParse only the checked bytes
Compare tags with ===Comparison time can leak the tagConstant-time compare, length checked first

Guardrails

Read more

Filippo Valsorda, “(Post-Quantum) Cryptographic Right Answers” (12 Oct 2026). The rule, the good and bad JSON, and the constant-time note on this page come from its HMAC section.

Hacker News discussion.

Sources: HMAC-SHA-256 and the 5 lists (Percival 2009, Ptacek 2015, Latacora 2018 and 2024, Valsorda 2026) from the post above.