Short answer. SD-JWT (RFC 9901, November 2025) replaces each hideable claim in a signed JWT with the hash of a disclosure: a small base64url-encoded JSON array of a random salt, the claim name and its value. The issuer signs the hashes; the holder later sends the signed JWT plus only the disclosures it chooses, joined with ~. A verifier recomputes the hashes and checks that each one appears in the signed payload. A key binding JWT (typ: kb+jwt), signed by a key named in the credential, ties a presentation to one verifier (aud), one request (nonce) and exactly these disclosures (sd_hash). SD-JWT VC adds the credential layer: typ: dc+sd-jwt, a credential type in vct, status and type metadata. It is still an Internet-Draft, but it is one of the two credential formats every EU Digital Identity Wallet must support, next to ISO/IEC 18013-5 mdoc.
Disclosures and digests
A disclosure for an object property is the array [salt, name, value]; for an array element it is [salt, value] (RFC 9901 §4.2). It is base64url-encoded as UTF-8 JSON, and the digest is the base64url SHA-256 of that encoded string, not of the JSON (§4.2.3). Because the hash covers the exact bytes the issuer chose, no JSON canonicalization is needed: the same claim encoded with different whitespace is a different disclosure with a different digest. Both points, checked against the RFC's own example:
RFC 9901 §4.2.3 digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces: TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
The issuer puts the digests in an _sd array in the payload, sorted or shuffled so their order says nothing, and may add decoy digests that match no disclosure (§4.2.5), so that the number of hidden claims does not leak. _sd_alg names the hash; SHA-256 is the default and mandatory to implement. The salt makes digests unguessable: without it, a verifier could hash ["birthdate", "1984-01-26"] for every plausible date and find the match. RFC 9901 recommends at least 128 random bits per salt, chosen independently for each claim (§9.3).
On the wire, an SD-JWT is <issuer-signed JWT>~<disclosure>~…~<disclosure>~, and a presentation with key binding replaces the final empty part with the key binding JWT: …~<disclosure>~<KB-JWT> (§4). The trailing ~ is how a verifier tells the two apart.
Issuer, wallet and verifier in 76 lines
The module below implements the flat case, where every hideable claim is a top-level property, which is enough to see every rule at work. Nested and recursive disclosures follow the same steps on inner objects (§6).
import { createHash, randomBytes } from "node:crypto";
import { SignJWT, calculateJwkThumbprint, decodeJwt, importJWK, jwtVerify } from "jose";
const b64url = (text) => Buffer.from(text, "utf8").toString("base64url");
// RFC 9901 §4.2.3: SHA-256 over the ASCII of the base64url Disclosure, then base64url.
export const digest = (disclosure) => createHash("sha256").update(disclosure, "ascii").digest("base64url");
const salt = () => randomBytes(16).toString("base64url"); // 128 bits (§9.3)
// --- Issuer ------------------------------------------------------------------
/** claims: always-visible claims; hidden: claims to make selectively disclosable. */
export async function issue({ issuerKey, holderJwk, claims, hidden, decoys = 2 }) {
const disclosures = [];
const sd = [];
for (const [name, value] of Object.entries(hidden)) {
const d = b64url(JSON.stringify([salt(), name, value]));
disclosures.push(d);
sd.push(digest(d));
}
for (let i = 0; i < decoys; i++) sd.push(digest(salt())); // decoy digests (§4.2.5)
sd.sort(); // order must not reveal which claim is which
const jwt = await new SignJWT({ ...claims, _sd: sd, _sd_alg: "sha-256", cnf: { jwk: holderJwk } })
.setProtectedHeader({ alg: "ES256", typ: "dc+sd-jwt", kid: "issuer-2026-10" })
.sign(issuerKey);
return `${jwt}~${disclosures.join("~")}~`;
}
// --- Holder ------------------------------------------------------------------
export async function present({ sdJwt, reveal, holderKey, aud, nonce }) {
const [jwt, ...rest] = sdJwt.split("~");
const chosen = rest.filter(Boolean).filter((d) => reveal.includes(JSON.parse(Buffer.from(d, "base64url"))[1]));
const presented = `${[jwt, ...chosen].join("~")}~`;
const kb = await new SignJWT({ aud, nonce, sd_hash: digest(presented) }) // sd_hash: §4.3.1
.setProtectedHeader({ alg: "ES256", typ: "kb+jwt" })
.setIssuedAt()
.sign(holderKey);
return presented + kb;
}
// --- Verifier ----------------------------------------------------------------
export async function verify(presentation, { issuerJwks, issuer, vct, aud, nonce, maxKbAge = 60 }) {
const parts = presentation.split("~");
const kbJwt = parts.pop(); // "" when there is no Key Binding JWT
if (!kbJwt) throw new Error("key binding required, none presented"); // §7.3 step 2
const [jwt, ...disclosures] = parts;
// §7.1 step 2: issuer signature, typ, algorithm, issuer, hash algorithm.
const { payload } = await jwtVerify(jwt, issuerJwks, { algorithms: ["ES256"], typ: "dc+sd-jwt", issuer });
if (payload._sd_alg !== "sha-256") throw new Error(`unsupported _sd_alg ${payload._sd_alg}`);
if (payload.vct !== vct) throw new Error(`unexpected vct ${payload.vct}`);
// §7.1 steps 3-5 (top-level object properties only, which is all this issuer emits).
const byDigest = new Map(disclosures.map((d) => [digest(d), d]));
if (byDigest.size !== disclosures.length) throw new Error("duplicate disclosure");
if (new Set(payload._sd).size !== payload._sd.length) throw new Error("digest appears twice");
const { _sd, _sd_alg, ...claims } = payload;
for (const sdDigest of _sd) {
const d = byDigest.get(sdDigest);
if (!d) continue; // undisclosed claim or decoy
byDigest.delete(sdDigest);
const decoded = JSON.parse(Buffer.from(d, "base64url"));
if (!Array.isArray(decoded) || decoded.length !== 3) throw new Error("not an object-property disclosure");
const [, name, value] = decoded;
if (name === "_sd" || name === "..." || name in claims) throw new Error(`claim name ${name} not allowed`);
claims[name] = value;
}
if (byDigest.size > 0) throw new Error("disclosure not referenced by the issuer-signed JWT"); // step 5
// §7.3 step 5: Key Binding JWT signed by the key in cnf, fresh, for us, over exactly this presentation.
const holderKey = await importJWK(payload.cnf.jwk, "ES256");
const { payload: kb } = await jwtVerify(kbJwt, holderKey, { algorithms: ["ES256"], typ: "kb+jwt", audience: aud, maxTokenAge: maxKbAge });
if (kb.nonce !== nonce) throw new Error("key binding nonce mismatch");
if (kb.sd_hash !== digest(`${parts.join("~")}~`)) throw new Error("sd_hash does not match the presented SD-JWT");
return { claims, holderThumbprint: await calculateJwkThumbprint(payload.cnf.jwk) };
}
export const peek = (jwt) => decodeJwt(jwt);
The verifier's rules map one to one to RFC 9901 §7.1 and §7.3: reject none; check the issuer and its key; understand _sd_alg; match each disclosure to exactly one digest; reject disclosures named _sd or ... or colliding with a visible claim; reject a digest that appears twice and a disclosure nothing refers to; then check the key binding JWT's signature with the key in cnf, its typ, its age, aud, nonce and sd_hash. Note step 1 of §7.3: whether key binding is required is the verifier's policy, never decided by whether the holder happened to send a KB-JWT.
Run, and cross-checked
The driver issues an identity credential with five hideable claims (name, surname, birth date, address and age thresholds), presents only the age thresholds to a shop, verifies the presentation with the module above and with @sd-jwt/sd-jwt-vc 0.23.0 from the OpenWallet Foundation, and then tries six manipulations against both:
RFC 9901 §4.2.3 digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces: TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
issued SD-JWT: 1438 chars, 5 disclosures, _sd has 7 digests (2 decoys)
presentation: 1385 chars, 1 disclosure
hand-written verifier: {"iss":"https://issuer.example","vct":"https://credentials.example/identity_credential","iat":1792499582,"exp":1824035582,"age_equal_or_over":{"18":true,"21":true,"65":false}}
@sd-jwt/sd-jwt-vc: age_equal_or_over {"18":true,"21":true,"65":false}, given_name undefined, kb verified
disclosure with a changed value | disclosure not referenced by the issuer-signed JWT | Unreferenced disclosure(s) detected in SD-JWT
same presentation, another verifier | unexpected "aud" claim value | Verify Error: Invalid Key Binding audience
same presentation, a new nonce | key binding nonce mismatch | Verify Error: Invalid Nonce
extra disclosure appended | sd_hash does not match the presented SD-JWT | Invalid sd_hash in Key Binding JWT
key binding JWT removed | key binding required, none presented | Key Binding JWT not exist
key binding by another key | signature verification failed | Verify Error: Invalid JWT Signature
The two verifiers agree on every case. Reading the lines:
- Selective disclosure works. The shop received
age_equal_or_over;given_nameisundefinedin the library's result because its disclosure was never sent. Both verifiers reconstructed the same claims. - A holder cannot edit a claim. Changing the age disclosure produced a digest the issuer never signed.
- A presentation is for one verifier and one request. Replayed to another audience, or answered with a stale nonce, it failed.
sd_hashfreezes the selection. Adding a disclosure after the wallet signed the key binding JWT broke it.- Without the wallet's key the credential is useless, if the verifier requires key binding: a stripped or re-signed key binding JWT was rejected.
And one thing the run shows that is a design lesson rather than a check: the shop asked for "over 18" and learned "not over 65" as well, because the issuer packed all thresholds into one disclosure. Granularity is decided at issuance. RFC 9901 §6.2 shows the structured form on an address object: each property gets its own disclosure, and the object keeps one _sd array with a digest per property. Issued that way, age_equal_or_over would let the wallet release "18": true alone.
What selective disclosure does not hide
Selective disclosure hides values, not the credential. Every presentation of one SD-JWT carries the same issuer signature, the same _sd digests and the same cnf key, so two verifiers who compare notes can tell that they saw the same credential, even if one received only an age and the other only a postal code. RFC 9901 §10.1 is direct about it: the format "does not meet the security properties for anonymous credentials", and an issuer that receives presentations back from a verifier can always recognize its own credential. The same applies to mdoc, which uses the same salted-hash approach.
The mitigation the RFC describes is batch issuance: the issuer gives the wallet several credentials with the same claims, and the wallet uses a different one for each verifier or even each session. For that to work, each credential in the batch needs its own salts and its own key binding key, and time claims such as iat and exp must be randomized or rounded, because exact timestamps are as linkable as a serial number. Batch issuance prevents verifiers from linking presentations among themselves; it cannot hide a presentation from the issuer itself. Status checks are a related leak: a verifier that asks the issuer about one credential tells the issuer where it is used, which is why the Token Status List lets verifiers download a compressed list covering many credentials instead of asking about one.
What SD-JWT VC adds
SD-JWT is a container. SD-JWT-based Verifiable Credentials defines how to use it for credentials:
typ: dc+sd-jwt, media typeapplication/dc+sd-jwt. Until version -06 the draft usedvc+sd-jwt; it changed to avoid thevcname used by the W3C Verifiable Credentials Data Model. Verifiers that met early implementations may still seevc+sd-jwt.vct, required: the credential type, a collision-resistant string. Types can have machine-readable type metadata for schemas and display.iss,cnf(the holder key, required when key binding is used),status(revocation through a Token Status List) and validity claims. These, together withvct, must not be made selectively disclosable: a verifier must always see who issued the credential, for whom it is bound, and whether it is still valid.
Status on 20 October 2026: SD-JWT VC is at draft -19 (31 August 2026), submitted to the IESG, not yet an RFC. The Token Status List it uses for revocation is approved and in the RFC Editor queue. RFC 9901 itself is published.
The EU Digital Identity Wallet
The revised eIDAS Regulation (Regulation (EU) 2024/1183) requires each Member State to provide at least one European Digital Identity Wallet within 24 months of the entry into force of its core implementing acts. Those acts, including Implementing Regulation (EU) 2024/2977 on person identification data, were published on 4 December 2024 and entered into force on 24 December 2024, which puts the deadline at 24 December 2026.
The technical side is described in the Architecture and Reference Framework, whose version 3.0.0 was released on 23 July 2026 (the ARF itself is informative; the implementing acts are the binding text). Its chapter 5 says:
- Wallets support two credential formats: ISO/IEC 18013-5 mdoc and SD-JWT VC. W3C VCDM 2.0 is optional, for non-qualified attestations only.
- mdoc credentials are presented in proximity over ISO/IEC 18013-5, and remotely over ISO/IEC 18013-7 or OpenID for Verifiable Presentations with the HAIP profile. SD-JWT VC credentials are presented remotely only, over OpenID4VP and HAIP; they "cannot be used in proximity presentations".
- The person identification data (PID) uses the mdoc document type
eu.europa.ec.eudi.pid.1and the SD-JWT VC typeurn:eudi:pid:1, with national types such asurn:eudi:pid:de:1in the same namespace (PID Rulebook).
The OpenID protocols around it are final: OpenID4VP 1.0 (July 2025), OpenID4VCI 1.0 (September 2025) and HAIP 1.0 (December 2025), which profiles SD-JWT VC and mdoc for high-assurance use.
SD-JWT VC and mdoc side by side
The ARF calls the two selective-disclosure mechanisms "conceptually identical": both sign salted digests of individual attributes. The difference is everything around them. The mdoc column uses the structure names of the ISO standard, which is not freely available; the open-source @auth0/mdl library exposes the same names in its types.
| SD-JWT VC | ISO/IEC 18013-5 mdoc | |
|---|---|---|
| Encoding | JSON, JWS, base64url | CBOR, COSE |
| Hidden attribute | disclosure [salt, name, value] | IssuerSignedItem with random, digestID, elementIdentifier, elementValue |
| Where digests live | _sd arrays in the issuer-signed JWT | valueDigests per namespace in the Mobile Security Object (MSO), signed by the issuer |
| Holder binding | key binding JWT, optional in the format | device key in the MSO and device authentication, mandatory |
| Issuer trust | issuer key, for example via x5c or issuer metadata | issuer certificate chain to an issuing authority |
| Presentation in the EUDI wallet | remote only (OpenID4VP + HAIP) | proximity (NFC, BLE, QR) and remote |
| Origin | IETF OAuth working group | mobile driving licence standard |
For a verifier, supporting the wallet means accepting both, which in practice means a library for each: mdoc parsing and COSE verification is not something to hand-roll.
Common mistakes
- Hashing the decoded JSON instead of the base64url string. Every digest will mismatch; the RFC example above is the test.
- Short or reused salts. They make hidden values guessable or linkable across claims.
- Treating a missing KB-JWT as "key binding not needed". The verifier's policy decides, not the presentation.
- Not checking
audandnoncein the key binding JWT. Then any presentation a holder ever made can be replayed to you. - Coarse disclosures. One disclosure per object reveals the whole object; design the structure for the questions verifiers will ask.
- Ignoring
status. A credential can be signed, unexpired and revoked.
Sources
- RFC 9901: Selective Disclosure for JSON Web Tokens, §4, §6, §7, §9
- draft-ietf-oauth-sd-jwt-vc, SD-JWT-based Verifiable Credentials, -19
- draft-ietf-oauth-status-list, Token Status List
- Regulation (EU) 2024/1183 and Implementing Regulation (EU) 2024/2977
- EUDI Wallet Architecture and Reference Framework, v3.0.0, chapter 5; PID Rulebook
- OpenID4VP 1.0, OpenID4VCI 1.0, HAIP 1.0
- @sd-jwt/sd-jwt-vc and @auth0/mdl