The context you sign
A verifiable credential is a claim, made by an issuer about a subject, that a
verifier can check without contacting the issuer. A diploma you can present to an
employer, a licence you can show an inspector, a membership a service can accept.
The cryptography is the easy part and has been for thirty years. The hard part is
that the verifier and the issuer have to agree about what the claim says,
and in the JSON-LD family of credential formats that agreement is mediated by a
@context resolved at verification time, on the verifier's machine,
possibly years later.
Almost every subtle problem in this space is a consequence of that one design choice. It is worth understanding why the choice was made before complaining about it.
What is actually signed
There are two families of proof in the credentials world and the difference is not cosmetic.
JWT-style proofs sign bytes. The credential is serialised, the serialisation is signed, and verification re-hashes exactly those bytes. Simple, fast, extremely well-understood, and brittle in one specific way: you must preserve the serialisation exactly, so you cannot reformat, reorder keys, or merge the credential into a larger document without destroying the proof.
Data Integrity proofs sign the graph. The credential is expanded to RDF, canonicalised — RDFC-1.0, formerly URDNA2015, which deterministically orders the quads and labels the blank nodes — hashed, and signed. Verification repeats that pipeline. Whitespace, key order, which of several equivalent spellings you chose: all irrelevant, because none of them survive into the canonical form.
That second property is why anyone bothers. A graph signature lets a holder take a credential, recompose it, present a subset of it, or embed it in a presentation alongside three others, and have all the signatures still verify. Selective disclosure schemes like BBS+ are built directly on it: you can prove you hold a signed statement about your date of birth without revealing the rest of the credential, because the signature is over a set of statements rather than over a blob.
The trade in one line. Signing bytes means you must preserve the
bytes. Signing the graph means you must preserve the meaning — which
means the mapping from bytes to graph must produce the same result for the
verifier as it did for the issuer. That mapping is the @context.
Then the context is part of the trusted computing base
Consider what a verifier does with a credential containing:
{
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://vocab.example.edu/credentials/v1"
],
"type": ["VerifiableCredential", "DegreeCredential"],
"issuer": "did:example:university",
"credentialSubject": {
"id": "did:example:ada",
"degree": { "type": "BachelorDegree",
"name": "Mathematics" }
},
"proof": { ... }
}
To verify, it must resolve both context URLs, expand the document, canonicalise the result, and compare the hash. The second URL belongs to the issuer. If its content changes, the expansion changes, the canonical form changes, and the signature no longer verifies — or, far worse, still verifies over a graph that says something different from what the issuer signed.
Hence @protected, which is in the credentials v2 context on essentially
every term. It makes a downstream redefinition an error rather than an override.
So the context is inside the trust boundary. Whoever controls that URL controls what signed credentials mean. That is not a hypothetical: a vocabulary maintainer who "cleans up" a term definition in place has retroactively altered the semantics of every credential ever issued against it.
The three defences, and why you need all three
The ecosystem's answer is not one mechanism but three, and each covers a different failure.
Protected terms stop a later context in the same document
from redefining an earlier one. Without them, a credential could include
the W3C context and then append a context redefining issuer, producing
a document that looks standard and expands to something else. Protection turns that
into a processing error.
Immutable, versioned context URLs stop the issuer's own vocabulary
from drifting. A context published at a URL that never changes its content means the
verifier in 2033 expands the credential exactly as the issuer did in 2026. This is
why the W3C contexts are versioned in the path — /credentials/v2, not
/credentials — and never edited afterwards.
Local caching with integrity checks stops the network from being in the loop at all. Every production verifier should ship with the contexts it accepts, pinned by hash, and refuse unknown ones rather than fetching them. A verifier that resolves arbitrary URLs named in a credential it was handed by an untrusted party has a server-side request forgery bug, whatever else it has.
A verifier that fetches is a verifier that can be steered.
The credential is attacker-supplied input. Its @context array is a
list of URLs the attacker chose. Fetching them is doing what the attacker said, on
your network, from inside your perimeter. Pin the contexts. Refuse the rest.
Where it actually goes wrong in practice
Four failures, roughly in order of how often they happen to real deployments.
1. The undefined term that disappears
The issuer adds honours: "First Class" to the credential subject, but
does not add honours to the vocabulary context. Expansion drops it.
Canonicalisation sees a graph without it. The signature is valid.
The credential verifies perfectly and does not contain the field.
This is the JSON-LD dropped key from the handbook, arriving in the worst possible place: a system where "it verified" is taken as proof that the content is what the issuer meant. Nothing in the verification pipeline catches it, because nothing is wrong — the issuer signed a graph, and that graph genuinely did not contain the field.
The defence is entirely on the issuer's side, and it is a build-time one: expand every credential shape you issue, and fail if any key drops. A credential issuer without a lossiness check in CI is issuing credentials whose contents it has not verified.
2. The unversioned context
A team publishes their vocabulary at https://vocab.example.edu/credentials
and edits it as the model evolves. Everything works, for about a year. Then a term's
@type coercion is corrected — genuinely corrected, it was wrong — and
every credential issued before the correction now expands to a different graph.
Signatures fail en masse, and the only remedy is to re-issue, which for a credential
handed to a person on a device is not a remedy at all.
The version goes in the URL, the URL never changes content, and you decide this before the first credential is issued, because there is no retrofit.
3. Canonicalisation is not compaction
A recurring implementation bug: someone assumes that because JSON-LD has a compaction algorithm, round-tripping a document through expand-then-compact yields stable bytes suitable for hashing. It does not. Compaction picks among equivalent spellings — a single value may come back as a one-element array, a term may be selected differently — and the bytes differ while the graph is identical.
Canonicalisation for signing is RDFC-1.0 over the N-Quads, and nothing else counts. If a proof implementation is hashing JSON, look closely at it.
4. Blank nodes, which are worse than they look
Every node in a credential without an explicit @id is a blank node.
Canonicalisation assigns them deterministic labels by inspecting graph structure, so
signing works — but blank nodes cannot be referred to from outside the document,
cannot be merged reliably with statements from elsewhere, and in pathological cases
make canonicalisation expensive enough to be a denial-of-service vector.
Give everything that might ever be referenced a real identifier. In the tooling here
this is L2.blank-node-minted: a node that got a blank identifier where
the model suggested one was expected. It is a warning rather than an error, because
plenty of intermediate structures legitimately have no identity — but in a
credential it is worth reading every one.
Is the graph-signing bet worth it?
Reasonable people disagree, and the disagreement has hardened into two camps that both ship.
The case against: you have taken a signature, which is a well-understood primitive with a small trusted computing base, and made it depend on a JSON-LD processor, an RDF canonicalisation algorithm, and the stable availability of documents at URLs. Each is a place for implementations to disagree, and two implementations that disagree about expansion produce a credential that verifies in one library and fails in another. The JWT camp points at this and says: sign the bytes, keep the base small, solve selective disclosure another way.
The case for: the composability is not a luxury. A holder collecting credentials from several issuers into one presentation, a verifier checking a subset, a selective-disclosure proof over individual statements — these are the actual use cases of the credential model, and they need a signature over meaning rather than over encoding. A byte signature makes the document atomic, and an atomic credential is a PDF with extra steps.
My own position is that the bet is defensible and the ecosystem under-invested in the consequences. Signing a graph is coherent only if you can see the graph — and for most of the last decade, the tooling to see what your credential actually expanded to was a REPL and a lot of squinting. The failure modes above are not exotic; they are what happens when a pipeline's most consequential step is also its least observable.
A checklist, if you are issuing
- Expand every credential shape in CI and fail on any dropped key. This one line catches failure #1, which is the one that silently issues an empty promise.
- Version your context in its URL and never edit a published version. Content-address it if you can; then immutability is mechanical rather than aspirational.
-
Mark load-bearing terms
@protected— anything a verifier's decision depends on. - Ship the contexts with the verifier, pinned by hash, and refuse to fetch anything else.
-
Give every referenceable node a real
@id. - Test against two independent implementations. Not two wrappers around the same library — two implementations. Divergence in expansion is a real thing and you want to find it before your verifiers do.
- Diff every vocabulary change by class, and treat a change that alters the expansion of an already-issued credential as what it is: a break of a signature you cannot un-break.
None of this is exotic engineering. It is the ordinary discipline of publishing an interface, applied to an artifact most teams do not think of as an interface. The credential is signed. The context is the thing that decides what was signed. Treat it accordingly.