jsoncanonicalizationsecurityadvanced

JSON Canonicalization (JCS): Making JSON Byte-for-Byte Reproducible

·8 min read·Advanced

The same data, a different string

{"a":1,"b":2} and {"b": 2, "a": 1} represent identical data — JSON objects are explicitly unordered by spec. But if you hash both strings, or compare them for equality, you get two completely different results, because a hash function and a string comparison don't know or care about JSON semantics — they only see bytes. This becomes a real problem the moment you need to sign JSON, cache it by content hash, or compare two payloads for genuine equality: whitespace, key order, and number formatting all change the bytes without changing the meaning.

What canonicalization actually does

JSON Canonicalization takes any JSON value and produces exactly one, deterministic serialization of it — the same input always produces the identical output string, no matter which language or library generated the original JSON. RFC 8785 defines the standard version, known as JCS (JSON Canonicalization Scheme).

json
// Input A
{"b": 2, "a": 1, "c": [3, 2, 1]}
json
// Input B — same data, different order and spacing
{
  "c": [3, 2, 1],
  "a": 1,
  "b": 2
}
text
// Both canonicalize to the exact same string:
{"a":1,"b":2,"c":[3,2,1]}

Note: JCS sorts object keys, but preserves array order — arrays are ordered by definition in JSON, so [3, 2, 1] stays [3, 2, 1], not sorted to [1, 2, 3].

The rules that make it deterministic

JCS specifies exact, unambiguous rules for every part of serialization:

AspectRule
Object keysSorted by their UTF-16 code unit values
WhitespaceNone — no spaces, no newlines
NumbersSerialized per ECMAScript's Number.toString() rules (no trailing zeros, no + on exponents)
StringsEscaped per a fixed minimal rule set — only what must be escaped is escaped
UnicodeNormalized encoding for identical characters

The numbers rule is the subtle one: 1.50, 1.5, and 1.5e0 are the same numeric value but different source text. JCS defines exactly how a number must be re-serialized so every implementation that follows the spec produces the identical digit sequence.

Why this matters for signatures

A digital signature works by hashing a payload and signing the hash — but that only proves anything if the *verifier* hashes the exact same bytes the *signer* did. If a payload is re-serialized anywhere along the way (a proxy re-encodes it, a library re-orders keys), the hash changes and a perfectly legitimate payload fails signature verification. This is precisely why JSON Web Signatures used with detached content, several Web Payments specs, and some blockchain/verifiable-credential systems mandate JCS: it guarantees that "the same data" really does mean "the same bytes" everywhere.

javascript
// npm install canonicalize
import canonicalize from "canonicalize";
import { createHash } from "crypto";

const payload = { b: 2, a: 1 };
const canonical = canonicalize(payload); // '{"a":1,"b":2}'
const hash = createHash("sha256").update(canonical).digest("hex");
// Any other system canonicalizing the same logical data gets the same hash

Other places "canonical JSON" shows up

  • Content-addressed storage — systems that derive a content ID from a hash of the JSON (a pattern used in some verifiable credentials and decentralized storage formats) need canonicalization so logically-identical documents produce the same address.
  • Idempotency / deduplication keys — hashing a canonicalized request body is a more reliable dedup key than hashing the raw request text, which can vary by client formatting.
  • Diffing and testing — canonicalizing two JSON payloads before comparing them turns "are these semantically equal" into a simple string-equality check, sidestepping key-order false negatives.
  • Cache keys — deriving a cache key from canonical JSON avoids cache misses caused purely by irrelevant key reordering upstream.

Canonicalization is not encryption or compression

It's worth being explicit about what JCS is *not*: it doesn't hide, shrink, or protect data — it only makes the *serialization* deterministic. The data is exactly as readable (or sensitive) as before; you still need TLS for transport security and proper encryption for confidentiality. Canonicalization solves a narrow, specific problem: "do these two logically-equal documents produce identical bytes."

Frequently asked questions

Not quite — JSON.stringify preserves the original key insertion order rather than sorting keys, and doesn't enforce JCS's exact number-formatting rules. Two different objects with the same data but built in a different key order will JSON.stringify to different strings.

Rarely — normal API traffic doesn't need byte-for-byte reproducibility. It matters specifically when you're signing payloads, deriving content hashes, or need cryptographic-grade equality checks across independent implementations.

Use a library implementing RFC 8785 specifically — canonicalize (JavaScript), json-canonicalization implementations exist for Python, Go, and Java. Don't hand-roll it; the number-formatting edge cases are easy to get subtly wrong.

For a quick manual check, JSONKit's JSON Diff tool compares JSON structurally and ignores key order — it won't give you a cryptographic guarantee, but it answers "is this the same data" for everyday debugging.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.