Skip to content
back to blog

I Shipped a Non-Injective Canonical String

6 min read

#security #webhooks #typescript #lessons


I maintain a small webhook signing library, webhook-hmac-kit. Version 1 built its HMAC input like this: v1:{timestamp}:{nonce}:{payload}, joined on a colon. Nothing stopped the nonce or the payload from containing a colon. That is the whole bug.

Take a message signed with nonce abc and payload a:b:c. The canonical string is v1:1700000000:abc:a:b:c. But the same bytes also read as nonce abc:a, payload b:c. Or nonce abc:a:b, payload c. All three splits produce the identical string, so all three verify under the same signature. The delimiter didn't pick a boundary. It picked one of several.

Why the nonce cache didn't help

The nonce is the value a replay cache keys on. An attacker who has seen one valid delivery can present it again with the split point moved over. Each variant hands the cache a nonce it has never seen. A Set-backed cache doing exactly what it was built to do waves the forged ones straight through anyway. I reproduced this: three separate accepted deliveries of one signed message, against a cache that correctly rejected the honest replay. Nothing was wrong with the cache. The nonce it was keying on wasn't stable.

The parsed payload gets truncated on the way through, too. For JSON that usually throws, because JSON is full of colons. So this half tends to fail closed. For form-encoded data, CSV, or plain text it truncates silently. That's the quieter bug. The replay bypass is the one that matters.

The fix

Version 2 moves the delimiter to a dot and constrains the nonce: ^[A-Za-z0-9_-]{1,64}$, no dots allowed. That makes the split unambiguous instead of merely unlikely to collide. The canonical value is now built as bytes rather than a template literal. The payload type widened to string | Uint8Array, so a caller can hand over the raw wire bytes instead of a string someone already decoded.

That last change turned out to matter more than I expected.

The bug the fix hid

I wrote a property test for the new canonical string before shipping it. fast-check over (timestamp, nonce, payload), asserting that no two distinct triples produce the same canonical string. It passed and I moved on.

It was green for the wrong reason. buildCanonicalString returns a string, and it genuinely is injective, a distinct string for a distinct triple. But signWebhook doesn't sign the string. It signs the UTF-8 encoding of the string, and encoding a JS string as UTF-8 is not injective. An unpaired surrogate on its own, \uD800, encodes to the same three bytes as the replacement character . So signWebhook({payload: '\uD800', ...}) and signWebhook({payload: '�', ...}) return the same signature, and a signature made over one verifies against the other.

The property couldn't see this. Random byte arrays almost never collide by chance. buildCanonicalString, the function I was asserting against, is a string-to-string map with no encoding step in it at all. I was testing the layer that was already correct.

The fix that made the property mean something: assert on the signed bytes, not the canonical string. And generate a payload arbitrary that can actually produce an ill-formed string. The part of test/canonical.property.test.ts that does this:

const payloadBytesArb = fc.oneof(
  fc.uint8Array({ maxLength: 24 }),
  fc.uint8Array({ min: 0xf8, max: 0xff, maxLength: 4 }),
);

The second arm draws lead bytes in 0xf80xff, which are invalid as the start of any UTF-8 sequence on their own. Every byte string built from them decodes to the same run of replacement characters, which is the exact collision the first property missed. Without that arm, the ill-formed case practically never gets generated, however many runs you add.

What a property test has to target

The lesson here is specific, not general. A property test is only as good as the function it calls. Say the property is "no two distinct inputs produce the same signature". Then the property has to call the signing function. Not a helper three layers upstream that happens to look like the interesting part. buildCanonicalString was a fine function to test. It was just the wrong one for this claim.

The adapters made the bug reachable from the wire, too. resolveRawBody decoded the incoming request body with raw.toString('utf-8') before handing it to the signer. Two different byte sequences on the wire could collapse to the same string before signing ever ran. Fixed the same way: carry the payload as bytes end to end. Only decode it if the caller explicitly hands over a string in the first place.

The compare

While I was in there the whole thing moved off node:crypto and onto Web Crypto. It now runs on Workers and Deno without a compat flag. That put the compare on crypto.subtle, and subtle.verify is the exact thing that had a timing bug. Node's WebCrypto HMAC verify wasn't constant-time until March 2026. CVE-2026-21713 was a timing side channel in memcmp() inside crypto_hmac.cc. The person who fixed it then amended the WebCrypto spec itself, w3c/webcrypto PR #553, adding a line requiring HMAC verification to run in constant time.

That line only exists in the editor's draft so far. I couldn't find a Web Platform Test for it. So it's a requirement with no test behind it, which is roughly how the Node bug went unnoticed for years in the first place. I moved verification to a blinded double-HMAC compare instead. Sign both digests under a fresh random key per call, and compare those. That way I'm not trusting a runtime's own verify to already hold a guarantee the spec only just started asking for.

Worth saying plainly: someone from Cloudflare Research, in the WebCrypto issue thread, calls this pattern a non-solution, an abuse of the API. He argues the platform should ship a real constant-time primitive instead of pushing every library into the same workaround. I still think the workaround is the safer default until that primitive exists. But the objection is fair, and I'm not pretending it isn't there.

The one-line lesson

An unescaped delimiter between two free-form fields is not a formatting choice. It's a parsing ambiguity. A property test aimed at the wrong function will tell you it's fine.

One more thing

Standard Webhooks, a spec with reference libraries in nine languages, signs {message_id}.{timestamp}.{payload}. Same shape as mine: three fields, dot-joined, none of them length-prefixed. The spec text already names the hazard, in almost the words I'd use for my own bug. It says the message id and the timestamp should not be user-controlled. Or, at the very least, should never be allowed to contain a dot. I didn't have that sentence in front of me when I picked a colon. It would have helped.

Full changes are in the 2.0.0 changelog.