All research

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

Seven RFC 8785 JCS libraries tested: six produce identical canonical JSON, one sorts keys by code point, worst-pair signature verification fails 10.2%.

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.

The blunt version JCS exists so that a signer and a verifier reduce a JSON value to exactly the same bytes before signing. It mostly works: six libraries across JavaScript, Python, Go, and Java were indistinguishable on our whole corpus. The risk is not that JCS is broken, it is that your signature is only as canonical as your least-conformant library, and at least one shipped library is non-conformant in precisely the way that breaks signatures. This matters now because agent cards, verifiable credentials, and JWS payloads are being canonicalized with JCS before they are signed. Test the exact library on both ends of your trust boundary, not the spec.

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:

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.

Cross-library divergence on 426 valid, unambiguous inputs, by category
CategoryInputsDivergentCause
Key sorting8014code point vs UTF-16 order
Number formatting1946integers above 253
Negative zero180all agree (-0 to 0)
Unicode escaping760all agree
Unicode normalization270all agree (no silent NFC/NFD)
Structural300all 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.

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.

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.

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

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