JCS signature verification: six of seven RFC 8785 libraries agree on the canonical form, one does not

Six of the seven RFC 8785 JSON canonicalization libraries we tested produce byte-identical output on every valid input; the seventh does not, and that single disagreement is a signature-verification split. We ran 432 valid JSON values through seven independent JCS implementations in five languages. All seven match the worked example in RFC 8785 Appendix B, so all seven are genuinely JCS. But one of them, the Rust crate serde_jcs, sorts object keys by Unicode code point instead of by UTF-16 code unit as the specification requires. On 4.7% of our valid inputs it emits different bytes from the other six. If it sits on one side of a signature and any of the other libraries sits on the other, a JSON object signed by one can fail to verify under the other. When we simulated that directly with HMAC, the worst library pairing failed to verify 10.2% of the messages it should have accepted.
Why one byte of disagreement is a security bug
Plain JSON gives you too much freedom. The same logical object can be written with its keys in any order, with any amount of whitespace, and with numbers spelled many equivalent ways. That flexibility is fine until you want to sign the object, because a signature is computed over bytes, not over meaning. If the signer and the verifier serialize the object differently, the verifier hashes different bytes and the signature does not check out. Worse, if an attacker can find two byte strings that one side treats as equal and the other treats as different, you get a canonicalization attack: a crafted message that verifies as one thing while meaning another. A 2026 survey catalogues canonicalization failures as a recurring vulnerability class in exactly these systems.
RFC 8785, the JSON Canonicalization Scheme, is the fix. It nails down every degree of freedom: object member names are sorted, numbers are serialized with the ECMAScript Number.prototype.toString algorithm, strings get the minimal legal escaping, and insignificant whitespace is dropped. Feed the same JSON value to any conformant implementation and you get the same bytes, so both ends of a signature agree on what was signed. The scheme is now load-bearing. The Agent-to-Agent protocol signs an agent card as a JWS over the card content canonicalized with JCS, and advises clients to verify at least one signature before trusting the card. Verifiable credentials and detached JWS flows do the same. The canonical bytes are the trust boundary.
So the interesting question is not whether the spec is sound. It is whether the libraries people actually deploy agree with each other. We measured it.
What we tested
We assembled seven independent JCS implementations across five languages, chosen because they are the ones a developer is likely to pull off a package registry:
canonicalize4.0.0 for JavaScript (npm), the widely used erdtman-lineage module.- The
cyberphone/json-canonicalizationreference implementation in Python, the author's own reference code. - The PyPI
jcspackage, version 0.2.1, a separate Python implementation. gowebpki/jcsv1.0.1 for Go.- The
cyberphone/json-canonicalizationreference implementation in Go. serde_jcs0.1.0 for Rust.java-json-canonicalization1.1 for Java (the erdtman Maven artifact).
Each ran in its own official-image container, so the result is reproducible and no library could influence another. We built a corpus of 440 crafted inputs, 432 of them valid JSON, tagged by category: number formatting near the IEEE-754 and ECMAScript boundaries, negative zero, key-sorting cases with characters that order differently under UTF-16 than under code point, Unicode escaping and normalization, duplicate keys, lone surrogates, and structural edge cases. We fed the identical bytes to every library and captured its canonical output, preserving the bytes exactly so that no divergence could be an artifact of our own tooling.
Two oracles ran over the outputs. The first is a straight differential: for each input, do the libraries that succeeded produce the same bytes? The second is the attack itself: take a fixed HMAC-SHA256 key, have a signer library canonicalize an input and tag it, then have a verifier library canonicalize the same input and check the tag. Every ordered pair of libraries, every input. A failure there is a cross-library signature that should verify and does not.
Before trusting any of it, we checked the ground truth. All seven implementations reproduce the canonical output in RFC 8785 Appendix B byte-for-byte, including its deliberately awkward numbers and its escaped string. They are all real JCS. That is what makes the disagreement below meaningful: it is not a broken library versus a correct one, it is seven correct-looking libraries, one of which is quietly wrong at an edge.
The result: 4.7% of valid inputs split, and always the same way
On the 426 valid, unambiguous inputs that every library accepted, six of the seven were byte-identical on all of them. Not close: identical, on every single value. The seventh, serde_jcs, disagreed on 20 of those 426 inputs, which is 4.7%, and it was the sole minority every time. When JCS libraries disagree, in our corpus, it is one library against the field.
| Category | Inputs | Divergent | Cause |
|---|---|---|---|
| Key sorting | 80 | 14 | code point vs UTF-16 order |
| Number formatting | 194 | 6 | integers above 253 |
| Negative zero | 18 | 0 | all agree (-0 to 0) |
| Unicode escaping | 76 | 0 | all agree |
| Unicode normalization | 27 | 0 | all agree (no silent NFC/NFD) |
| Structural | 30 | 0 | all agree |
Two categories carry the entire divergence, and the first is the one that matters.
The key-sort split: code point versus UTF-16 code unit
RFC 8785 is explicit that object member names are sorted by their UTF-16 encoding, comparing code units. This detail exists because JCS inherits ECMAScript's string ordering, and ECMAScript strings are sequences of UTF-16 code units. For characters in the Basic Multilingual Plane, code-unit order and code-point order are the same, so almost nobody notices. They diverge only for supplementary-plane characters, the ones above U+FFFF that are encoded as a surrogate pair. An emoji such as U+1F600 has code point 0x1F600, which is greater than a Basic Multilingual Plane character like U+E000. But its UTF-16 encoding starts with the high-surrogate code unit 0xD83D, which is less than 0xE000. So under the two rules, the two keys sort in opposite orders.
Fourteen of our key-sorting inputs hit exactly this fault line, and serde_jcs sorts by code point where the other six sort by UTF-16 code unit. Here is the smallest case, an object with two keys:
input: {"😀": 1, "\uE000": 2}
six libs: {"😀":1,"\uE000":2} 😀 first (0xD83D < 0xE000)
serde_jcs: {"\uE000":2,"😀":1} 😀 last (0x1F600 > 0xE000)
The private-use character U+E000 is shown here as an escape for readability; JCS actually emits it as a literal byte, and both libraries agree on that part. What they do not agree on is the order of the two members, and that is enough. Sign the object on the left with HMAC or JWS, hand it to a verifier running serde_jcs, and the verifier canonicalizes to the object on the right, computes a different tag, and rejects a signature that was perfectly valid. Reverse the roles and the same thing happens in the other direction. The two libraries have a stable, silent disagreement about what this object's canonical form is.
This is not an exotic new discovery about the scheme. It is the classic JCS conformance trap, called out in the RFC itself and in prior write-ups on canonicalization. What our measurement adds is the base rate: of seven shipped, installable libraries that all pass the RFC's own test vector, one still gets this wrong today. Conformance to the headline example does not imply conformance at the edge.
The number split, and why we report it separately
The other six divergent inputs are integers larger than 253, such as 9007199254740993. JCS specifies that numbers are serialized through the ECMAScript number-to-string algorithm, which runs through an IEEE-754 double. Above 253 a double can no longer represent every integer, so 9007199254740993 rounds to 9007199254740992. Six libraries do exactly that. serde_jcs preserves the integer exactly instead. We flag this honestly as a weaker finding than the key-sort split: RFC 8785 is defined only over numbers in the IEEE-754 double range, so an integer above 253 is arguably out-of-spec input that a caller should never pass. But it is still syntactically valid JSON, it still produces a byte-level split between real libraries, and a system that accepts numeric identifiers larger than a JavaScript safe integer can still walk into it. We keep it in the differential and out of the headline.
Everything else held. Negative zero canonicalized to 0 everywhere. No library silently applied Unicode normalization, so a string in NFC and the same string in NFD stayed distinct across all seven, which is correct: JCS does not normalize. String escaping was uniform. The disagreements are narrow and specific, which is what makes them dangerous rather than obvious.
Turning the split into a signature failure
A divergence in canonical bytes is only interesting if it breaks something. So we made it break something. Using one fixed HMAC-SHA256 key, we had every library act as signer and every library act as verifier, over every valid input where both produced bytes: 42 ordered pairs, 11,754 verification checks in total. A check fails when the signer's canonical bytes and the verifier's canonical bytes differ, because then the tags differ.
Across all pairs, 1.87% of verifications failed. That number is diluted by the six libraries that never disagree with each other, so the honest way to read it is by pair. Every pair that does not involve serde_jcs verified at 100%. Every pair that pairs serde_jcs with a conformant library failed on the split inputs. The worst pairing, the cyberphone Go reference as signer and serde_jcs as verifier, failed 15 of 147 checks, a 10.2% signature-verification failure rate between two libraries that both pass the RFC test vector. In a deployment that mixes languages across a trust boundary, and agent-to-agent systems do exactly that, this is the failure you would actually see.
The other axis: some libraries reject what others accept
Alongside the byte-level splits, we found a large accept-versus-reject surface: 288 inputs where at least one library returned output and another returned an error. This is not a canonical-form disagreement, but it is an interoperability hazard of its own, and it splits the libraries into camps.
- Top-level scalars. The two reference-lineage parsers,
cyberphonein Go anderdtmanin Java, reject a bare0or"x"as top-level input; the others canonicalize it. If your protocol ever signs a bare JSON scalar, half these libraries will not process it at all. - Duplicate keys. Given
{"a":1,"a":2}, all three Go and Java parsers reject the object outright, while the JavaScript, Python, and Rust implementations silently keep the last value. Duplicate-key handling is unspecified enough that a signer and verifier can disagree on whether the message is even legal. - Lone surrogates. An unpaired surrogate in a string is invalid JSON, and the libraries scatter: one emits the replacement character U+FFFD, one passes the raw non-UTF-8 bytes straight through, and the rest reject. Any of those behaviors on one end and a different one on the other is a divergence waiting to happen.
We keep this out of the headline number because it is accept-versus-reject, not byte-versus-byte, but it is the same lesson: the guarantee lives in the implementation, not in the three-letter acronym.
The mirror image of our CBOR result
We ran the same experiment one encoding over, on binary CBOR, and got the opposite shape. There, deterministic CBOR was not deterministic across libraries: 26% of values came out with more than one canonical encoding, and the disagreement was spread across the whole field of libraries. JCS is the tidy sibling. Its rules are tighter and its libraries mostly agree, so the divergence collapses to one outlier and a handful of edge inputs rather than a quarter of everything. That contrast is the practical takeaway. A canonicalization scheme's safety is not a property of the scheme alone; it is the product of the scheme's tightness and the conformance of the specific libraries in your path. JCS gives you a better scheme. It does not give you a free pass on checking your libraries.
Test your own library in ten seconds
You do not need our harness to find out which side of this split you are on. Canonicalize this object with whatever JCS library you deploy:
{ "😀": 1, "\uE000": 2 }
If the output puts the emoji key first, your library sorts by UTF-16 code unit and matches the RFC and the majority. If it puts the private-use key first, your library sorts by code point and will disagree with a conformant peer on any object whose keys mix supplementary-plane and Basic Multilingual Plane characters. Run the same check on both ends of every trust boundary that signs JSON, especially where the two ends are different languages, and especially if any of your keys can contain attacker-influenced Unicode.
How we measured it, so a skeptic can trust the numbers
The dataset is entirely our own and synthetic. We did not touch any third-party service, scan anything, or collect any real-world data; every input is a JSON value we wrote to probe a specific edge. The libraries are public open-source packages, tested at their published versions, and named because naming the behavior of a spec implementation is the whole point of a conformance test, not an accusation against anyone.
- Isolation. Each implementation ran in its own official-image container, reading the identical corpus and writing its canonical output with the bytes preserved exactly, so a divergence is the library's, not our pipeline's.
- Ground truth. All seven reproduce the RFC 8785 Appendix B canonical output byte-for-byte, which is how we know they are genuine JCS rather than approximate serializers.
- Two independent oracles. A byte-level differential and a live HMAC sign-then-verify across all 42 ordered library pairs. The signature failures are the differential made concrete, not a separate claim.
- Honest bucketing. We separate byte-level splits on valid input (the headline) from accept-versus-reject behavior and from arguably out-of-spec inputs, so no number is doing more work than it earns.
The harness, corpus generator, and per-library runners are kept so the method is reproducible; only the aggregate is what we report here.
Why this matters, and the honest limits
Canonicalization is quietly becoming load-bearing again. The push to give autonomous agents verifiable identities, through signed agent cards and verifiable credentials, runs the trust decision through a JCS-then-sign pipeline, often with the signer and verifier written in different languages by different teams. That is the exact condition under which a one-library conformance gap turns into a verification split. The good news from this census is that JCS interoperates well: pick two conformant libraries and they are indistinguishable. The bad news is that conformance is not guaranteed by the label, and the one place a shipped library still gets it wrong, key ordering for supplementary-plane characters, is both attacker-reachable and easy to miss.
A result without its limits is marketing, so here are the boundaries.
- Seven libraries, not the whole ecosystem. These are common, installable implementations, but there are more, and a C or C-sharp implementation we did not test could sit on either side of the split. The method generalizes; the exact counts are specific to this set at these versions.
- Low probability in typical payloads. The key-sort split only bites when object keys contain supplementary-plane characters, which are rare in ordinary agent cards or credentials with ASCII field names. The risk is real but conditional, and an attacker who controls a key is the one who makes the condition true on purpose.
- Versions move. Libraries get fixed. A crate that sorts by code point today may conform tomorrow, which is exactly why the durable advice is to test the specific version you deploy rather than to trust or distrust a name.
- The number split is edge input. Integers above 253 are outside the range JCS is defined for, so we treat that divergence as a weaker, secondary finding and keep it out of the headline.
Frequently asked questions
What is the JSON Canonicalization Scheme (JCS)?
JCS, defined in RFC 8785, is a set of rules that turns a JSON value into one deterministic byte string. It sorts object member names, serializes numbers with the ECMAScript number-to-string algorithm, applies minimal string escaping, and drops insignificant whitespace. Two parties starting from the same JSON value produce the same bytes, so they can hash or sign those bytes and match. JCS is what lets you sign JSON without agreeing in advance on its exact spelling.
Why does JCS signature verification fail across different languages?
It fails when the signer's library and the verifier's library disagree on the canonical bytes for the same value. In our test six of seven implementations agreed byte-for-byte, but one Rust crate sorted object keys by Unicode code point instead of by UTF-16 code unit as RFC 8785 requires. For an object whose keys include an emoji or other supplementary-plane character, the two libraries emit the keys in a different order, so the tag over the object differs and verification fails.
Do JCS implementations produce the same canonical output?
Mostly. Across 426 valid, unambiguous inputs fed to seven implementations in five languages, six were byte-identical on every input and all seven matched RFC 8785 Appendix B. One diverged on 4.7% of inputs, and every divergence involved either a supplementary-plane object key or an integer above 2 to the 53rd power. JCS interoperates well on ordinary data and splits only at specific edges.
How does JCS prevent a signature bypass?
It removes the freedom plain JSON gives you, key order, whitespace, and the many spellings of a number, so one value maps to one byte string and cannot be re-encoded into a different-looking equivalent. That guarantee holds only if both sides use a conformant implementation. A non-conformant one reintroduces the ambiguity JCS was meant to remove, which is why implementation conformance, not merely using JCS, is the property that matters.
How does A2A use JCS to sign agent cards?
In the Agent-to-Agent protocol an agent card is a JSON document describing an agent's identity and capabilities. Its signature is a JWS over the card content after that content is canonicalized with JCS, and clients are advised to verify at least one signature before trusting a card. Because the signed bytes are the JCS output, a client whose JCS library canonicalizes differently from the signer's computes a different input to verification.
What is a canonicalization confusion attack?
It exploits a disagreement between how a signer and a verifier reduce a message to canonical form. If the two produce different bytes for the same logical value, an attacker can craft input that verifies as one thing while meaning another, or make a legitimate signature fail. Canonicalization is a recurring vulnerability class in systems that sign structured data, because the security depends on every party computing the identical canonical form.
Is RFC 8785 an official IETF standard?
RFC 8785 is an IETF Informational RFC published in June 2020. It is a stable, referenceable specification of the JSON Canonicalization Scheme, cited by later work on signed JSON, verifiable credentials, and agent-to-agent security. Informational status means it is not a mandatory Internet Standard, but it is the definition implementations point to.
Related reading
- Deterministic CBOR is not deterministic across libraries. The same differential one encoding over, on binary CBOR, where 26% of values got more than one canonical form.
- Remote MCP server security: what a census of 19,321 servers shows. The agent-tooling surface these signed cards are meant to protect, measured at population scale.
- What the certs prove. Reading cryptographic assurances off the bytes instead of trusting the label, the same method applied to certificates.
Is the crypto you depend on actually there?
Our $100 check reads your real cryptographic posture the way an attacker maps it, on scope you have verified you own and authorized in writing, with a senior operator on the readout. Where you sign JSON, and whether both ends canonicalize it the same way, is exactly the kind of edge that check is for.
Book a $100 check