SD-JWT-VC explained
If you've worked with JWTs, you already understand 80% of SD-JWT-VC. This page covers the other 20% — the part that makes selective disclosure work without trusting the holder.
The problem
A bank wants to verify your age — specifically, are you over 18? The state issued you a credential containing your full birth date. Two bad outcomes:
- You hand over your birth date. The bank now knows more than it asked for. Privacy regulators frown.
- You hand over a "yes I'm 18+" claim signed by you. The bank can't trust you — you could lie.
SD-JWT-VC threads the needle: the issuer (state) signs claims in a form where you can selectively reveal any subset without invalidating the issuer's signature.
The shape
An SD-JWT-VC is three parts joined by ~:
<issuer-jwt>~<disclosure-1>~<disclosure-2>~<key-binding-jwt>
- The issuer JWT is a normal JWS — header + payload + signature.
Payload includes
iss,iat,exp,vct(credential type),cnf.jwk(the holder's public key — see "key binding" below), and for each selectively-disclosable claim, a SHA-256 hash under the_sdarray. - Each disclosure is a base64url-encoded JSON triple
[salt, claim_name, claim_value]. Hashing this triple gives the digest the issuer JWT pre-committed to. - The key-binding JWT (KB-JWT) is signed by the holder using the
key in
cnf.jwk. It contains the verifier's nonce and audience, plus asd_hashthat hashes the entire presentation transcript (the issuer JWT + disclosures the holder chose to include).
The trick
The issuer signs the digests, not the values. So the holder can drop disclosures from the presentation — the verifier still gets a valid signature, just with fewer matched claims.
issuer signs: {
_sd: ["abc...", "def...", "ghi..."], // hashes of (salt, name, value)
vct: "urn:eudi:pid:1",
iss: "https://state.example",
cnf: { jwk: <holder-pubkey> },
...
}
Holder sends ,
omits the disclosures for birth_date, family_name, address,
etc. Verifier:
- Verifies the issuer signature on the JWT.
- Hashes each disclosure, finds it in
_sd. - Verifies the KB-JWT signature using
cnf.jwk. - Checks the KB-JWT's
aud,nonce, andsd_hash.
What the verifier sees: just age_over_18: true. What it doesn't
see: everything else the issuer also vouched for. Issuer doesn't have
to be online. Holder can't lie. Verifier learns the minimum.
Key binding
Without the KB-JWT, an attacker who steals an SD-JWT-VC could replay it. With it, the attacker would need both the SD-JWT and the holder's private key (which lives in the wallet's secure enclave).
The audience binding (aud in the KB-JWT) means a presentation made
to bank A can't be replayed against bank B — even if the attacker
gets both.
Why the EU picked this
Mobile driver's licenses (mDL, ISO 18013-5) had been the front-runner. mDL has selective disclosure too, but uses CBOR + ECDSA-on-COSE — a stack the average web developer doesn't have lying around. SD-JWT-VC is JOSE — every web crypto library already supports it.
Both formats are required by the EU; SD-JWT-VC is what most non-government verifiers will see in practice, because mDL has tighter rules around device-attested wallets.
In Gramota
The format is @gramota/sd-jwt's domain — parseSdJwt,
verifyHashBinding, issueSdJwt. The high-level packages
(@gramota/verifier, @gramota/issuer) wrap these with the surface
most people want; drop down to @gramota/sd-jwt only if you need
non-standard flows.
For the wire-format crawl, see the IETF draft. For working code, see Getting started.