Engineering · 2026-10-22
Signing at the Edge: JCS Canonicalization Pitfalls in Multi-Language Clients
By J. W. Bouckaert
Signatures cover bytes, and JSON has many
A digital signature is computed over a byte string. JSON is a data model with many byte strings for the same value: whitespace is optional, object keys have no order, 4.50 and 4.5 are the same number, and the string é can be written as one code point or two. A verifier that rebuilds the bytes from a parsed object will produce a different sequence from the signer's unless both agree, character by character, on how every value is written. That agreement is what a canonicalization scheme provides, and RFC 8785, the JSON Canonicalization Scheme, is the one identity standards have settled on: the W3C's Data Integrity cryptosuites eddsa-jcs-2022 and ecdsa-jcs-2019, Recommendations since May 2025, apply "the JSON Canonicalization Scheme [RFC8785] to a JSON serialization of the unsecuredDocument" and hash the result.
JCS is short and its rules are simple to state. No whitespace. Keys sorted. Numbers printed the way ECMAScript prints them. Strings escaped minimally and encoded as UTF-8. Each rule is defined by reference to JavaScript, because the scheme's design goal was that JSON.stringify on a sorted object should produce it. That choice made JCS trivial in JavaScript and subtle everywhere else, and the subtleties are the subject of this post. We build signers in Deno and a wallet SDK in TypeScript and talk to verifiers written in whatever our customers use, so the failure modes below are the ones we test for.
The six divergences
1. Sorting by code point instead of by UTF-16 code unit. Section 3.2.3 of the RFC sorts property names "as arrays of UTF-16 code units", compared as unsigned integers, and warns that sorting UTF-8 or UTF-32 "would differ and thus be incompatible". The RFC's own test vector makes the difference concrete: among keys including the Hebrew letter dalet with dagesh (U+FB33) and the grinning-face emoji (U+1F600), the emoji sorts first, because in UTF-16 it is the surrogate pair D83D DE00 and D83D is less than FB33. Python compares strings by "the numerical Unicode code points", so json.dumps with sort_keys=True puts the dalet first, and in Python 3.11 the sorted output for those keys is the reverse of the RFC's. Go's encoding/json sorts map keys by their UTF-8 bytes, which gives the same wrong order. The verified Python implementation in the RFC's Appendix G, and the PyPI jcs package derived from it, sort on the key encoded as UTF-16 big-endian for exactly this reason; the Go implementation gowebpki/jcs converts each key through utf16.Encode before comparing. Keys outside the Basic Multilingual Plane are rare in identity payloads, which is why this bug survives: it fails only on the credential with an emoji in a claim name.
2. Number formatting. Section 3.2.2.3 requires numbers to be serialised per ECMAScript's Number::toString "including the 'Note 2' enhancement", which means the shortest digit string that round-trips, ties broken to even, fixed notation for magnitudes from 1e-7 up to 1e21 and scientific notation with an explicit exponent sign beyond that, and both zeros written as 0. The RFC's example turns [333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001] into [333333333.3333333,1e+30,4.5,0.002,1e-27]. Every other runtime's default printer differs somewhere. Python renders 1.0, -0.0, 1e16 and 1e-7 as 1.0, -0.0, 1e+16 and 1e-07; JCS wants 1, 0, 10000000000000000 and 1e-7. Java's Double.toString renders ten million as 1.0E7 and a ten-thousandth as 1.0E-4, and Jackson uses Double.toString unless its fast writer is switched on. Go's encoding/json formats floats "as if by ES6 number to string conversion", with the right thresholds, and still prints negative zero as -0, which JCS forbids. The compliant implementations solve this by carrying their own printer: the Java one uses Ryu, the Rust crates depend on ryu-js, a port "adjusted to comply to the ECMAScript number-to-string algorithm". A signer that emits 1.0 where the verifier expects 1 has produced a valid signature over the wrong bytes.
3. Escaping too much. Section 3.2.2.2 escapes exactly the control characters U+0000 to U+001F, the backslash and the double quote, and requires everything else to be emitted "as is" in UTF-8. Go's encoding/json escapes <, >, & and the line separators U+2028 and U+2029 by default, to be safe inside HTML; SetEscapeHTML(false) stops the first three and, in the version we tested, still escapes U+2028. Python's json.dumps defaults to ensure_ascii=True, which escapes every non-ASCII character as \uXXXX; a signer that forgets ensure_ascii=False produces bytes no JCS verifier will match on any payload containing an accented name. The RFC's rule is the strict one and the libraries' defaults are the lenient ones, so the bug is silent until the first non-ASCII claim.
4. Precision and range. Section 3.1 requires that "JSON number data MUST be expressible as IEEE 754 double-precision values", and Appendix B advises that integers stay within plus or minus 9007199254740991, which RFC 8259 calls the interoperable range. A 64-bit identifier written as a bare number is parsed to a double by JavaScript and loses its low bits; Rust's serde_json_canonicalizer notes that arbitrary precision "is lost" on the way through, and Bishop Fox's 2021 study found a 96-digit number decoded to several different values across parsers, including 0 and 9223372036854775807. Appendix D of the RFC gives the remedy, wrap such numbers as strings, and identity payloads should treat every identifier that way regardless.
5. Duplicate keys. JCS forbids them, as I-JSON does, and RFC 8259 explains why the prohibition matters: with duplicates "the behavior of software that receives such an object is unpredictable. Many implementations report the last name/value pair only." The consequences are not theoretical. CVE-2017-12635 in Apache CouchDB arose "due to differences in the Erlang-based JSON parser and JavaScript-based JSON parser": a user document with two "roles" keys was validated on one and stored with the other, so a non-admin user could make themselves an administrator. Bishop Fox's lab example sends a cart item with "qty": -1 and "qty": 1; the Python service that priced the order kept the last key and the Go service that charged it kept the first, and a USD 700 order was charged USD 300. Their remediation for parser maintainers was to "generate fatal parse errors on duplicate keys". A canonicaliser that silently keeps one is signing a document the verifier may read differently.
6. Unicode normalisation. JCS does none, on purpose. Section 3.1 states that "JCS-compliant string processing does not take this into consideration" and that every component in a scheme depending on JCS "MUST preserve Unicode string data 'as is'". So the string Å as U+00C5 and the string Å as U+0041 U+030A canonicalise to different bytes, and neither the RFC nor the W3C cryptosuites that use it require a normalisation step before canonicalising. Where the two forms come from is the operational point: Apple's HFS Plus stored file names in a variant of NFD, keyboard input on most platforms produces NFC, and a name copied from one into the other changes bytes without changing its appearance. PRECIS username profiles in RFC 8265 apply NFC before comparison for this reason. A signing scheme has to pick a form at the boundary, apply it before the signer sees the string, and apply the same form before the verifier does, because JCS will faithfully preserve whichever form each side happened to receive.
A test vector set that catches all six
Each divergence has a small input that exposes it. A signer's test suite in any language should canonicalise these and compare bytes against a JavaScript reference.
| Case | Input | JCS output | Common wrong output |
|---|---|---|---|
| Sort order | keys U+FB33 and U+1F600 | U+1F600 first | U+FB33 first (Python, Go) |
| Integer float | 1.0 | 1 | 1.0 (Python, Java) |
| Negative zero | -0.0 | 0 | -0.0 (Python, Java), -0 (Go) |
| Threshold | 1e21 and 1e-7 | 1e+21 and 1e-7 | 1e+21 and 1e-07 (Python); 1.0E21 and 1.0E-7 (Java) |
| Escaping | "a&b" and "é" | "a&b" and "é" | "a\u0026b" (Go default), "\u00e9" (Python default) |
| Big integer | 18446744073709551615 | error, or the value as a string | 18446744073709552000 |
| Duplicate key | {"a":1,"a":2} | error | {"a":2} |
| Normalisation | U+00C5 and U+0041 U+030A | two different byte strings, both preserved | silently equal after a library normalised one side |
The RFC's authors published a large file of number test cases against the development portal, and the Appendix G implementations were verified against it; a team porting JCS to a new language should run that file before trusting a single signature.
Two ways to avoid the problem
Canonicalisation exists so that a verifier can reconstruct the signed bytes from a parsed object. There is an older approach that removes the need: sign the bytes as sent, and carry them. RFC 7515, JWS, does this. The signing input is the base64url-encoded header, a period, and the base64url-encoded payload, over "an arbitrary sequence of octets"; the verifier never re-serialises anything. RFC 9901, SD-JWT, extended the idea to selective disclosure and said why: "no canonicalization needs to be performed before base64url encoding because the digest is calculated over the base64url-encoded value itself". Its Appendix B names the two approaches, canonicalisation and "source string hardening", and chooses hardening "as it allows for simple and reliable interoperability without the requirement for a canonicalization library". The cost is that the bytes travel with the signature; the benefit is that every pitfall above disappears.
Our credential path takes the hardening route. Each credential is a standard ES256 JWS whose signing input is the base64url header and payload joined by a period, with a detached ML-DSA-65 proof over the same input, as described in the hybrid signatures post; the holder proofs the wallet SDK builds are constructed the same way; and callback webhooks are an HMAC over the raw request body, which is why the integration guide insists on verifying before parsing. A verifier in Go, Python or Java checks bytes it received rather than bytes it rebuilt, and the language it is written in does not matter.
We use JCS in one place, and it is instructive that it is the place where hardening is impossible. The audit chain's checkpoint statement is a small object built on the server from database state, hashed, and signed; there are no original bytes to carry, because the statement is derived rather than received. The canonicaliser for it is a deliberate subset of RFC 8785: keys sorted by JavaScript string comparison, which is UTF-16 code-unit order; no whitespace; strings as JSON.stringify emits them; and integers only, with any other number, and any undefined, function or bigint value, rejected with an error rather than rendered. Refusing floats removes divergence number two entirely, at the cost of a statement format that cannot carry one; since the statement never needs to, that is the right trade, and a verifier in another language has one fewer printer to get right. The cryptographic agility post sketched a suite table with JCS as the canonicalisation for a future envelope; if that envelope is built, the checkpoint subset, with integers only and strings wrapped, is the version of JCS it will use.
A checklist for a multi-language signer
- Decide first whether you need canonicalisation at all. If the verifier can receive the exact bytes, sign those and carry them, as JWS and SD-JWT do; every rule below becomes moot.
- If you canonicalise, use an implementation verified against the RFC's test material in your language, and read how it sorts keys and prints numbers before trusting it. Your standard library's JSON encoder is not that implementation in Python, Go or Java.
- Wrap every identifier and every integer wider than 53 bits as a string, and consider refusing non-integer numbers in signed payloads outright.
- Reject duplicate keys at parse time, on both sides.
- Normalise strings to one Unicode form at the input boundary, document which, and do it before signing and before verifying, since JCS will not.
- Keep a cross-language test vector set, the eight rows above at minimum, and run it in every SDK's test suite, because a signature that verifies in the language it was written in tells you nothing about the others.
Left open
The RFC is Informational and an independent submission; nothing forces a library to follow it, and the standards that cite it do so without adding a normalisation step. The W3C cryptosuites are the first widely deployed use of JCS across languages, and the interoperability reports from wallet and verifier implementers over the next year will show which of the six divergences bite in practice. Our expectation, from testing our own edges, is that sort order and number printing account for most of it and that Unicode normalisation accounts for the cases nobody diagnoses. The post-quantum identity work is where our signatures get harder; canonicalisation is where they get wrong, and the two problems have nothing to do with each other.