Webhook signature check passed in staging for weeks, then started rejecting about 4% of production events. No pattern in event type, amount, or time of day.
The handler used express.json() to parse the body, then verified the HMAC against JSON.stringify(req.body). Parse, re-serialize, compare. It looks harmless, and it matches the signature whenever the sender's serializer and ours produce identical bytes.
They don't always. The gateway's backend escapes forward slashes ("https:\/\/shop.example\/return") and non-ASCII characters ("Jos\u00e9"). JSON.stringify does neither. So any event carrying a return URL, a cardholder name with an accent, or a descriptor with a slash got a different byte sequence and a different HMAC. Our staging fixtures were all ASCII names and had no URLs, so every test event passed.
The bad fix is to normalize the output until it matches. It will break again the next time someone adds a field or the sender changes a library version.
The actual fix: the signature is over the bytes that were sent, so verify the bytes that were received. Capture the raw body before any parser touches it (express.raw() or the verify callback on express.json()), compute HMAC-SHA256 over that buffer, compare with timingSafeEqual, and only then JSON.parse.
We also added one fixture with a slash, an accented name, and an emoji in the descriptor. It fails loudly if anyone puts a parser back in front of the check.
Has anyone hit a variant of this with a gateway that signs a canonicalized body instead of the raw one?
Top comments (0)