JSON canonicalization: why the same object hashes two ways
Key order, number formatting and escape style all change the bytes. A stable hash has to pin those rules at serialization, not hope JSON.stringify is deterministic.
Two hashes for one object is rarely a hash problem. It is serialization with no fixed rules. The output of JSON.stringify depends on the insertion order of keys, and nothing guarantees that when an object is assembled in several places.
JSON.stringify({ a: 1, b: 2 }) // '{"a":1,"b":2}'
JSON.stringify({ b: 2, a: 1 }) // '{"b":2,"a":1}' ← same data
Pin down three things
Key ordering. Sort by Unicode code point, applied recursively.
Number form. 1, 1.0 and 1e0 are the same value in JSON but different bytes. The usual rule is the shortest decimal form, with exponent notation forbidden.
String escaping. \/ equals /, and \u00e9 equals é. You must decide which characters get escaped and which pass through.
An implementation that is good enough
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') {
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new Error('non-finite');
return JSON.stringify(value); // accept JS shortest form
}
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return '[' + value.map(canonicalize).join(',') + ']';
}
const keys = Object.keys(value as object).sort();
const parts = keys.map(
(k) => JSON.stringify(k) + ':' + canonicalize((value as Record<string, unknown>)[k]),
);
return '{' + parts.join(',') + '}';
}
It fixes key order but not numbers. 1.0 is just 1 in JS, so the language handles that one. If you parse someone else’s JSON and re-serialize, 1e2 becomes 100, which is the normalization you want anyway.
When you actually need it
| Scenario | Needed | Why |
|---|---|---|
| Content-addressed storage | yes | same content, same address |
| Request signing | yes | client and server must agree |
| Cache keys | yes | otherwise equivalent requests miss |
| Idempotency keys | yes | otherwise replays look new |
| Plain logging | no | nobody reads key order |
Extra rule for signing
When signing requests, both sides must share one canonical form and that form belongs in the API docs. JCS (RFC 8785) exists for exactly this: it limits numbers to ECMAScript Number::toString output and ordering to UTF-16 code units.
Do not invent your own. Two hand-written canonicalizers on the two sides is a promise that one day they disagree on some boundary value.
When a hash is unstable, check serialization before the hash. Nearly every hash mismatch ends at key order.

Comments
…