The rule
Anchor the raw signed bytes. Never re-serialise, re-canonicalise, or round-trip a decoded
payload before hashing it. Canonicalisation applies to unsigned objects; a signed token is
already a byte string, and those are the bytes the signature covers.
Why re-canonicalising breaks the bind
A compact JWS isheader.payload.signature. The payload is base64url of some JSON. It is tempting to decode that payload, canonicalise it, and hash the result, since the object is right there and JCS gives a canonical form.
That produces a different digest, and the record stops recomputing:
- The signature covers
header.payloadas literal ASCII. Re-canonicalising throws away the exact bytes that were signed. - The issuer’s serialiser and your canonicaliser need not agree on key order, whitespace, or number form. JCS fixes that for the canonical form, but the token was not necessarily produced in canonical form to begin with.
- Anything the canonicaliser normalises, such as
1.0becoming1, silently changes the preimage.
jws_anchor_v1 set pins this as a hard negative: for the same mandate, sha256(JCS(decoded_payload)) and sha256(raw_signed_bytes) are recorded as two distinct values with an invariant asserting they must never be equal.
SD-JWT: anchor the issuer JWT, not the presentation
Selective disclosure makes the failure sharper. An SD-JWT is an issuer JWT followed by tilde-separated disclosures:
Anchoring a presentation therefore binds your record to one particular disclosure subset. Present a different subset later and the anchor no longer matches, even though nothing was tampered with.
The issuer-JWT anchor is disclosure-invariant: it is stable across every presentation derived from that credential, which is what an anchor is supposed to be.
Where this compounds with canonicalisation
The worst case is a payload that is itself canonicalisation-sensitive.jws_anchor_v1 includes one deliberately: its payload carries U+2028 and the 1.0 integral-float form, both jcs_edge_v1 classes.
For that vector, the re-canonicalised digest diverges from the signed-bytes digest specifically because of the canonicalisation subtlety. The anchoring bug is worst exactly where the canonicalisation edge cases live, which is why the two sets are siblings.
Conformance set
jws_anchor_v1 pins 6 vectors and 4 invariants. Signing uses the RFC 8032 section 7.1 Test 1 Ed25519 keypair, and EdDSA is deterministic, so a given signing input always yields byte-identical tokens. Anyone regenerates the exact tokens and anchors rather than taking the published values on trust.
The four invariants:
I1 recanon diverges
Re-canonicalising a decoded payload never reproduces the signed-token anchor.
I2 disclosure-invariant
The issuer-JWT anchor is stable across disclosures; presentation, issuance and issuer JWT are three distinct digests.
I3 signatures verify
Every signed token verifies under the RFC 8032 section 7.1 public key, in all eight crypto-capable implementations.
I4 canon-attributable
The canon-sensitive divergence is attributable to a jcs_edge_v1 case: literal U+2028 and
1.0 rendered as 1.How it is validated, stated as two populations
Five of the six vectors aresigned_bytes rather than canonicalisation vectors, so the set is deliberately not reported as a single uniform grid:
Elixir and Kotlin participate in the JCS side only and are not claimed for Ed25519 verification. Full matrix with library and version detail: ten-implementation attestation.
Checklist for implementers
1
Buffer the raw bytes
Keep the token exactly as received. Do not parse-and-reserialise before hashing.
2
Pick the anchor form deliberately
For SD-JWT, anchor the issuer JWT (before the first
~) unless you specifically intend to bind one disclosure subset.3
Canonicalise only unsigned objects
JCS is the rule for objects you are hashing directly, not for payloads lifted out of a signed token.
4
Verify against the vectors
Run
jws_anchor_v1 in your language. If your verifier re-canonicalises anywhere, vector 002 will catch it.JCS Canonicalisation Substrate
The canonicalisation floor this anchoring rule sits on top of.
Conformance vector sets
The full corpus, including jws_anchor_v1 and its cross-implementation matrix.