Hash and sign JSON in canonical form
Serialize JSON with the RFC 8785 JSON Canonicalization Scheme, so equal values hash, sign and deduplicate to the same bytes whatever their key order.
Last updated
After reading this guide, you will know
- Why
JSON.stringifyis the wrong input for a hash or a signature - How to hash and deduplicate JSON with
@sdxc/json-canonical - How to sign a JSON value so a receiver that re-serializes it still verifies
JSON.stringify writes members in the order they were inserted, so { a: 1, b: 2 } and
{ b: 2, a: 1 } are equal values with different text, and a hash, a signature or an ETag
computed from that text differs too. The
JSON Canonicalization Scheme (JCS) fixes every
choice the serializer makes, so equal values always produce the same bytes. This guide
writes that form with @sdxc/json-canonical and uses it to
deduplicate documents and sign values that travel as data.
npm add @sdxc/json-canonical @sdxc/result @sdxc/cryptoSerialize a value canonically
canonicalize writes no whitespace, sorts object members at every depth, and writes numbers
and strings exactly as ECMAScript does. Arrays keep their order:
import { canonicalize } from "@sdxc/json-canonical";
canonicalize({ b: [4.5, "€"], a: { d: true, c: null } });
// success('{"a":{"c":null,"d":true},"b":[4.5,"€"]}')
canonicalize(JSON.parse('{"n": 1E30, "x": 0.000001, "z": -0}'));
// success('{"n":1e+30,"x":0.000001,"z":0}')Hash a document to deduplicate it
A hash over the canonical bytes names a value rather than one spelling of it. Store it in a column with a unique index, and a second import of the same document with its keys reordered is refused instead of stored twice:
import { canonicalize } from "@sdxc/json-canonical";
import { Hex, sha256 } from "@sdxc/crypto";
import { isFailure, success } from "@sdxc/result";
export async function contentHash(value: unknown) {
let canonical = canonicalize(value);
if (isFailure(canonical)) return canonical;
let digest = await sha256(canonical.data);
if (isFailure(digest)) return digest;
return success(Hex.encode(digest.data));
}Sign a value that travels as data
A receipt or a grant is stored, embedded in other documents and parsed again, so its text changes on the way. Sign its canonical form, carry the signature beside it, and verify by canonicalizing what arrives:
import { hmac, Hex } from "@sdxc/crypto";
import { canonicalize } from "@sdxc/json-canonical";
import { isFailure, success } from "@sdxc/result";
export interface Receipt {
id: string;
customer: string;
total: string;
issuedAt: string;
}
export interface SignedReceipt {
receipt: Receipt;
signature: string;
}
export async function signReceipt(secret: string, receipt: Receipt) {
let canonical = canonicalize(receipt);
if (isFailure(canonical)) return canonical;
let mac = await hmac.sign(secret, canonical.data);
if (isFailure(mac)) return mac;
return success<SignedReceipt>({ receipt, signature: Hex.encode(mac.data) });
}
export async function verifyReceipt(secret: string, signed: SignedReceipt) {
let canonical = canonicalize(signed.receipt);
if (isFailure(canonical)) return false;
let valid = await hmac.verify(secret, canonical.data, signed.signature);
return !isFailure(valid) && valid.data;
}Read the secret from env in the caller and refuse to sign when it is unset. hmac.verify
compares in constant time, so a tampered receipt fails closed.
Handle a value JCS refuses
canonicalize fails on anything without one JSON spelling: NaN, undefined, a cycle, and
any object other than a plain one, Date included. The error's pointer names the field:
import { canonicalize } from "@sdxc/json-canonical";
import { isFailure } from "@sdxc/result";
let result = canonicalize({ items: [1, Number.NaN] });
if (isFailure(result)) result.error.pointer; // "/items/1"
canonicalize({ issuedAt: new Date() }); // fails at "/issuedAt"
canonicalize({ issuedAt: new Date().toISOString() }); // succeedsConvert dates to ISO strings and drop undefined members before you canonicalize.
Test the signature
The test that matters is the one JSON.stringify would fail: a reordered receipt still
verifies, and a changed total does not.
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";
import { signReceipt, verifyReceipt } from "~/app/services/receipts";
const SECRET = "test-receipt-secret";
test("a reordered receipt verifies and an edited one does not", async () => {
let signed = unwrap(
await signReceipt(SECRET, {
id: "r1",
customer: "c1",
total: "10.00",
issuedAt: "2026-10-24T00:00:00Z",
}),
);
let reordered = JSON.parse(
'{"total":"10.00","issuedAt":"2026-10-24T00:00:00Z","customer":"c1","id":"r1"}',
);
expect(await verifyReceipt(SECRET, { ...signed, receipt: reordered })).toBe(true);
let edited = { ...signed.receipt, total: "0.00" };
expect(await verifyReceipt(SECRET, { ...signed, receipt: edited })).toBe(false);
});Run it from the terminal
Recompute a stored hash, or see the exact bytes a signature covers, from a document someone sent you:
sdxc pkg json-canonical canonicalize payload.json
sdxc pkg json-canonical hash payload.json
sdxc pkg json-canonical hash payload.json --alg sha-512 --base64urlhash prints the SHA-256 of the canonical bytes in lowercase hex, the digest contentHash
stores. canonicalize exits 1 naming the JSON Pointer of a value JCS refuses, and 2 when the
input is not JSON. Use a package from the terminal covers what every package command line shares.
Where to go next
Idempotent writes and JSON Merge Patch — the idempotency fingerprint compares JSON bodies in this canonical form.
Sign and verify HTTP requests — sign the request itself when the JSON travels as a body.
@sdxc/json-canonical— every refused value and the number format.