DEV Community

Payneteasy
Payneteasy

Posted on

Webhook signature fails only when the payload has a slash in it

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 (3)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

Verify against raw bytes only, and the whole class of bug disappears. JSON.stringify(req.body) is a serialization claim about a library version, not about the sender, so any signing spec that asks for a byte-for-byte match is implicitly asking you to stop pretending you own the body. The fixture idea is the durable part: a slash, an accented character, and an emoji covers three independent escaping behaviors, and a regression test that only holds one of them will pass while production still 4%s. Worth adding a case where the descriptor field is absent entirely, since some serializers emit null and others drop the key.

Collapse
 
payneteasy profile image
Payneteasy •

Thanks, that’s a sharp way to put it: re-serializing is a claim about your library, not about the sender. Good call on the absent-field case, we’ll add it. One nuance: once you verify against the raw bytes, null versus a missing key no longer affects the signature check itself, because you’re hashing whatever arrived. Where it still bites is after the check: in parsing code that assumes the key is always there, and with senders that sign a canonicalized body instead of the raw one. With those, the canonical form has to define exactly how a missing field is handled, or the two sides can disagree. So the fixture belongs in both test suites: one for the verifier, one for the handler.

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

the canonicalization point is the exact boundary. once the sender signs a normalized form, the thing you're trusting isn't the payload anymore, it's their normalization spec, and missing-field handling is where two implementations silently diverge. splitting the fixture across the two suites makes that concrete: the verifier test pins what bytes got hashed, the handler test pins what a null means downstream. we started asking senders who sign canonicalized bodies for their normalization doc before go-live, after one integration spent a weekend discovering both sides treated absent keys differently.