The claim you can drop: an HMAC canonicalization lesson
Binding a value into a signature is necessary but not sufficient. If the way you serialize claims before signing is ambiguous, an attacker can reshape a request so a bound claim silently disappears — and the signature still verifies.
A signed promise is only as good as its encoding
A sound, common pattern for releasing a resource — a file, an API call, a capability — is a signed grant: the server computes an HMAC over the thing being granted plus a bag of claims (an expiry, a usage cap, a subject), and hands the client a URL carrying those claims and the signature. The client can present the grant but cannot forge one. The reasoning everyone relies on is simple: every constraint is part of what was signed, so changing any of them breaks the signature.
That reasoning has a hidden dependency. It assumes the mapping from “a set of claims” to “the bytes that get signed” is injective — that two different claim sets can never produce the same signed string. When that assumption quietly fails, the signature stops meaning what you think it means, and a bound claim can be dropped without breaking anything.
The pitfall: an ambiguous canonical form
Suppose you serialize claims the obvious way — sort them and join them as key=value pairs with an ampersand — then HMAC the result:
exp=1799999999&jti=abc&max=1
It looks canonical: same claims, same order, same bytes. But consider what happens when a value is allowed to contain the very characters you use as delimiters. These two different claim sets — one with three claims, one with two —
{ exp: 1799999999, jti: "abc", max: 1 }{ exp: 1799999999, jti: "abc&max=1" }
serialize to the identical string exp=1799999999&jti=abc&max=1. One signature is valid for both. The encoding is not injective.
Why that is exploitable, not just untidy
The danger appears at verification time, because the verifier rebuilds the claim set from whatever the client sent. Take a legitimately issued one-time link:
?exp=1799999999&jti=abc&max=1&sig=…
An attacker who holds it folds max into the neighboring value and drops the real max parameter:
?exp=1799999999&jti=abc%26max%3D1&sig=…
The verifier decodes jti to abc&max=1, canonicalizes the two claims it now sees, and gets the same bytes the signature was computed over — so the HMAC check passes. But the reconstructed claim set has no max claim at all. Any logic gated on “is there a usage cap?” is skipped, and the one-time link becomes unlimited. The same move drops a bound subject or an assurance level: whatever you believed the signature was protecting, it isn’t, because the claim is no longer there to check. Nothing was forged and no key leaked — the attacker only exploited a serialization that couldn’t tell “a value containing a delimiter” apart from “another claim.”
The fix: make the encoding unambiguous
The rule is short: the function from claims to signed bytes must be injective. Any of these achieve it:
- Percent-encode every key and value before joining, so a literal
&or=inside a value becomes%26/%3Dand can never impersonate a delimiter. - Length-prefix each field, so boundaries are explicit rather than inferred from a delimiter.
- Serialize a structured object (canonical JSON, CBOR) with one well-defined encoding, and sign that.
All three share the same property: given the signed bytes, exactly one claim set produces them. Once that holds, the original intuition — “change any claim and the signature breaks” — is finally true, because a reshaped request now produces different bytes and fails the check. A second, defense-in-depth habit: make your access decisions from the same claim set the signature was verified over, not from a fresh read of the raw request. If “does this grant have a usage cap?” is answered from the validated claims rather than from “is there a max query parameter?”, an ambiguous encoding has one less way to hurt you.
A familiar family of bugs
This is not exotic; it is a member of a well-known family. XML signature wrapping, JWT and JSON canonicalization mismatches, SQL and log injection, HTTP request smuggling — all are the same shape: two layers disagree about where one field ends and the next begins. Whenever a security decision depends on parsing a serialized structure, the question to ask is not “did I include the field?” but “can the boundaries between fields be moved by someone who only controls the values?”
Principles worth keeping
- Binding is necessary, not sufficient. A claim is only protected if the encoding that carries it into the signature is unambiguous.
- Canonicalization must be injective. Distinct inputs must never collide to the same signed bytes. Encode, length-prefix, or serialize a structured object — never join untrusted values with the delimiter they might contain.
- Decide from what you verified. Read the claims the signature covered, not the raw request, when you make the access decision.
- Ask where the boundaries are. Most serialization vulnerabilities are boundary confusion between two layers. If a value can contain your delimiter, your delimiter is not a boundary.
We surfaced this while hardening our own open-source File Gate module ahead of its first release candidate: a pre-release security review found exactly this pattern in the grant signer, and it was fixed — the canonical form now percent-encodes every claim, with a regression test proving a folded claim no longer validates — before the release shipped. The lesson generalizes well past that one module: any system that signs a bag of claims and trusts the result is making a bet on its own serialization. Make the encoding injective, and it is a bet you win by construction.