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.
“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.
{"foo": 123}; the handler hashes {"foo":123}. One space or a different key order changes the bytes, so the tag no longer matches."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.
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="
}
Read the body (or the inner string) as bytes. Do not parse it yet.
Hash those exact bytes with the shared key.
Check the lengths match first, then compare the tags with a constant-time function.
Parse the same bytes you checked, and act only on what they contain.
One copy of the message: checked, then parsed, then used.
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.
| Mistake | Effect | Rule now |
|---|---|---|
| Re-serialise JSON, then hash | Spacing or key order differs; genuine requests fail | Hash the bytes received |
| Check one parse, use another | Duplicate keys let unsigned values through | Parse only the checked bytes |
Compare tags with === | Comparison time can leak the tag | Constant-time compare, length checked first |
express.raw on the webhook route before any JSON parser touches it.JSON.stringify(req.body).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.
Sources: HMAC-SHA-256 and the 5 lists (Percival 2009, Ptacek 2015, Latacora 2018 and 2024, Valsorda 2026) from the post above.