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.

← Back to all posts

Comments

…