Kurz gesagt. SD-JWT (RFC 9901, November 2025) ersetzt jeden verbergbaren Claim in einem signierten JWT durch den Hash einer Disclosure: eines kleinen, base64url-kodierten JSON-Arrays aus einem zufälligen Salt, dem Namen des Claims und seinem Wert. Der Aussteller signiert die Hashes; der Inhaber sendet später das signierte JWT und nur die Disclosures, die er auswählt, verbunden durch ~. Ein Verifizierer berechnet die Hashes neu und prüft, ob jeder davon in der signierten Payload steht. Ein Key-Binding-JWT (typ: kb+jwt), signiert mit einem Schlüssel, den der Nachweis nennt, bindet eine Präsentation an einen einzigen Verifizierer (aud), eine einzige Anfrage (nonce) und genau diese Disclosures (sd_hash). SD-JWT VC ergänzt die Nachweisschicht: typ: dc+sd-jwt, einen Nachweistyp in vct, Status und Typ-Metadaten. Es ist noch ein Internet-Draft, aber eines der beiden Nachweisformate, die jede EU Digital Identity Wallet unterstützen muss, neben mdoc nach ISO/IEC 18013-5.
Disclosures und Digests
Eine Disclosure für eine Objekteigenschaft ist das Array [salt, name, value], für ein Array-Element ist es [salt, value] (RFC 9901 §4.2). Die Disclosure wird als UTF-8-JSON base64url-kodiert, und der Digest ist der base64url-kodierte SHA-256-Hash dieses kodierten Strings, nicht des JSON (§4.2.3). Weil der Hash genau die Bytes abdeckt, die der Aussteller gewählt hat, ist keine JSON-Kanonisierung nötig: Derselbe Claim, mit anderem Whitespace kodiert, ist eine andere Disclosure mit einem anderen Digest. Beides, geprüft am Beispiel aus RFC 9901 selbst:
RFC 9901 §4.2.3 digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces: TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
Der Aussteller schreibt die Digests in ein Array _sd in der Payload, sortiert oder gemischt, damit ihre Reihenfolge nichts verrät, und darf Decoy-Digests hinzufügen, die zu keiner Disclosure passen (§4.2.5), damit die Zahl der verborgenen Claims nicht nach außen dringt. _sd_alg nennt die Hashfunktion; SHA-256 ist der Standardwert und muss von jeder Implementierung unterstützt werden. Das Salt macht Digests unerratbar: Ohne Salt könnte ein Verifizierer ["birthdate", "1984-01-26"] für jedes plausible Datum hashen und den Treffer finden. RFC 9901 empfiehlt mindestens 128 Bit Zufall pro Salt, für jeden Claim unabhängig gewählt (§9.3).
Bei der Übertragung ist ein SD-JWT <issuer-signed JWT>~<disclosure>~…~<disclosure>~, und eine Präsentation mit Key Binding ersetzt den letzten, leeren Teil durch das Key-Binding-JWT: …~<disclosure>~<KB-JWT> (§4). Am abschließenden ~ unterscheidet ein Verifizierer die beiden Formen.
Aussteller, Wallet und Verifizierer in 76 Zeilen
Das folgende Modul implementiert den flachen Fall, in dem jeder verbergbare Claim eine Eigenschaft auf oberster Ebene ist; das genügt, um jede Regel in Aktion zu sehen. Verschachtelte und rekursive Disclosures durchlaufen dieselben Schritte an inneren Objekten (§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);
Die Regeln des Verifizierers entsprechen eins zu eins RFC 9901 §7.1 und §7.3: none ablehnen; den Aussteller und seinen Schlüssel prüfen; _sd_alg verstehen; jede Disclosure genau einem Digest zuordnen; Disclosures mit dem Namen _sd oder ... ablehnen, ebenso solche, die mit einem sichtbaren Claim kollidieren; einen doppelt vorkommenden Digest und eine Disclosure, auf die nichts verweist, ablehnen; dann die Signatur des Key-Binding-JWT mit dem Schlüssel in cnf prüfen, dazu seinen typ, sein Alter, aud, nonce und sd_hash. Beachte Schritt 1 von §7.3: Ob Key Binding verlangt wird, legt die Richtlinie des Verifizierers fest; es hängt nie davon ab, ob der Inhaber zufällig ein KB-JWT mitgeschickt hat.
Ausführung und Gegenprüfung
Das Treiberskript stellt einen Identitätsnachweis mit fünf verbergbaren Claims aus (Vorname, Nachname, Geburtsdatum, Adresse und Altersschwellen), präsentiert einem Shop nur die Altersschwellen, prüft die Präsentation mit dem Modul oben und mit @sd-jwt/sd-jwt-vc 0.23.0 der OpenWallet Foundation und testet dann beide mit sechs Manipulationen:
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
Die beiden Verifizierer stimmen in jedem Fall überein. So lesen sich die Zeilen:
- Selektive Offenlegung funktioniert. Der Shop hat
age_equal_or_overerhalten;given_nameist im Ergebnis der Bibliothekundefined, weil die zugehörige Disclosure nie gesendet wurde. Beide Verifizierer haben dieselben Claims rekonstruiert. - Ein Inhaber kann keinen Claim ändern. Die geänderte Alters-Disclosure ergab einen Digest, den der Aussteller nie signiert hat.
- Eine Präsentation gilt für einen Verifizierer und eine Anfrage. Einer anderen Audience erneut vorgelegt oder mit einer veralteten Nonce beantwortet, scheiterte sie.
sd_hashfriert die Auswahl ein. Nachdem die Wallet das Key-Binding-JWT signiert hatte, machte eine zusätzlich angehängte Disclosure die Präsentation ungültig.- Ohne den Schlüssel der Wallet ist der Nachweis nutzlos, wenn der Verifizierer Key Binding verlangt: Ein entferntes oder neu signiertes Key-Binding-JWT wurde abgelehnt.
Und eines zeigt der Lauf, das eher eine Lektion für die Gestaltung ist als eine Prüfung: Der Shop hat nach „über 18“ gefragt und zusätzlich „nicht über 65“ erfahren, weil der Aussteller alle Schwellen in eine einzige Disclosure gepackt hat. Die Granularität wird bei der Ausstellung festgelegt. RFC 9901 §6.2 zeigt die strukturierte Form an einem Objekt address: Jede Eigenschaft bekommt ihre eigene Disclosure, und das Objekt behält ein Array _sd mit einem Digest pro Eigenschaft. So ausgestellt, würde age_equal_or_over es der Wallet erlauben, "18": true allein freizugeben.
Was selektive Offenlegung nicht verbirgt
Selektive Offenlegung verbirgt Werte, nicht den Nachweis. Jede Präsentation desselben SD-JWT trägt dieselbe Ausstellersignatur, dieselben Digests in _sd und denselben Schlüssel in cnf; zwei Verifizierer, die ihre Daten abgleichen, können also erkennen, dass sie denselben Nachweis gesehen haben, selbst wenn der eine nur ein Alter und der andere nur eine Postleitzahl erhalten hat. RFC 9901 §10.1 sagt das offen: Das Format „erfüllt nicht die Sicherheitseigenschaften anonymer Nachweise“, und ein Aussteller, der Präsentationen von einem Verifizierer zurückbekommt, kann seinen eigenen Nachweis immer wiedererkennen. Dasselbe gilt für mdoc, das denselben Ansatz mit gesalzenen Hashes verwendet.
Die Abhilfe, die RFC 9901 beschreibt, ist die Ausstellung im Stapel (Batch Issuance): Der Aussteller gibt der Wallet mehrere Nachweise mit denselben Claims, und die Wallet verwendet für jeden Verifizierer oder sogar für jede Sitzung einen anderen. Damit das funktioniert, braucht jeder Nachweis im Stapel eigene Salts und einen eigenen Key-Binding-Schlüssel, und Zeit-Claims wie iat und exp müssen randomisiert oder gerundet werden, denn exakte Zeitstempel lassen sich genauso verketten wie eine Seriennummer. Die Ausstellung im Stapel verhindert, dass Verifizierer Präsentationen untereinander verketten; vor dem Aussteller selbst kann sie eine Präsentation nicht verbergen. Statusabfragen sind ein verwandtes Leck: Ein Verifizierer, der den Aussteller nach einem einzelnen Nachweis fragt, verrät ihm, wo dieser Nachweis verwendet wird. Deshalb können Verifizierer bei der Token Status List eine komprimierte Liste herunterladen, die viele Nachweise abdeckt, statt nach einem einzelnen zu fragen.
Was SD-JWT VC ergänzt
SD-JWT ist ein Container. SD-JWT-based Verifiable Credentials legt fest, wie er für Nachweise eingesetzt wird:
typ: dc+sd-jwt, Medientypapplication/dc+sd-jwt. Bis Version -06 verwendete der Entwurfvc+sd-jwt; geändert wurde das, um den Namenvczu vermeiden, den das W3C Verifiable Credentials Data Model verwendet. Verifizierer, die mit frühen Implementierungen zu tun haben, sehen unter Umständen nochvc+sd-jwt.vct, Pflicht: der Nachweistyp, ein kollisionsresistenter String. Typen können maschinenlesbare Typ-Metadaten für Schemas und die Anzeige haben.iss,cnf(der Schlüssel des Inhabers, Pflicht, wenn Key Binding verwendet wird),status(Widerruf über eine Token Status List) und die Gültigkeits-Claims. Diese Claims dürfen, ebenso wievct, nicht selektiv offenlegbar gemacht werden: Ein Verifizierer muss immer sehen, wer den Nachweis ausgestellt hat, an wen er gebunden ist und ob er noch gültig ist.
Stand vom 20. Oktober 2026: SD-JWT VC liegt in der Entwurfsversion -19 (31. August 2026) vor, ist bei der IESG eingereicht und noch kein RFC. Die Token Status List, die es für den Widerruf verwendet, ist genehmigt und liegt in der Warteschlange des RFC Editor. RFC 9901 selbst ist veröffentlicht.
Die EU Digital Identity Wallet
Die überarbeitete eIDAS-Verordnung (Verordnung (EU) 2024/1183) verpflichtet jeden Mitgliedstaat, innerhalb von 24 Monaten nach Inkrafttreten ihrer zentralen Durchführungsrechtsakte mindestens eine Europäische Brieftasche für die Digitale Identität bereitzustellen. Diese Rechtsakte, darunter die Durchführungsverordnung (EU) 2024/2977 über Personenidentifizierungsdaten, wurden am 4. Dezember 2024 veröffentlicht und traten am 24. Dezember 2024 in Kraft; die Frist endet damit am 24. Dezember 2026.
Die technische Seite beschreibt das Architecture and Reference Framework, dessen Version 3.0.0 am 23. Juli 2026 veröffentlicht wurde (das ARF selbst ist informativ; verbindlich sind die Durchführungsrechtsakte). Sein Kapitel 5 legt fest:
- Wallets unterstützen zwei Nachweisformate: mdoc nach ISO/IEC 18013-5 und SD-JWT VC. W3C VCDM 2.0 ist optional und nur für nicht qualifizierte Attributsbescheinigungen vorgesehen.
- mdoc-Nachweise werden vor Ort über ISO/IEC 18013-5 präsentiert und aus der Ferne über ISO/IEC 18013-7 oder über OpenID for Verifiable Presentations mit dem Profil HAIP. Nachweise im Format SD-JWT VC werden nur aus der Ferne präsentiert, über OpenID4VP und HAIP; sie „können nicht für Präsentationen vor Ort verwendet werden“.
- Die Personenidentifizierungsdaten (PID) verwenden den mdoc-Dokumenttyp
eu.europa.ec.eudi.pid.1und für SD-JWT VC den Typurn:eudi:pid:1, mit nationalen Typen wieurn:eudi:pid:de:1im selben Namespace (PID Rulebook).
Die OpenID-Protokolle rund um die Wallet sind final: OpenID4VP 1.0 (Juli 2025), OpenID4VCI 1.0 (September 2025) und HAIP 1.0 (Dezember 2025), das SD-JWT VC und mdoc für den Einsatz mit hohem Vertrauensniveau profiliert.
SD-JWT VC und mdoc im Vergleich
Das ARF nennt die beiden Mechanismen der selektiven Offenlegung „konzeptionell identisch“: Beide signieren gesalzene Digests einzelner Attribute. Der Unterschied liegt in allem, was sie umgibt. Die mdoc-Spalte verwendet die Strukturnamen des ISO-Standards, der nicht frei verfügbar ist; die Open-Source-Bibliothek @auth0/mdl verwendet in ihren Typen dieselben Namen.
| SD-JWT VC | mdoc (ISO/IEC 18013-5) | |
|---|---|---|
| Kodierung | JSON, JWS, base64url | CBOR, COSE |
| Verborgenes Attribut | Disclosure [salt, name, value] | IssuerSignedItem mit random, digestID, elementIdentifier, elementValue |
| Wo die Digests liegen | Arrays _sd im vom Aussteller signierten JWT | valueDigests pro Namespace im Mobile Security Object (MSO), vom Aussteller signiert |
| Bindung an den Inhaber | Key-Binding-JWT, im Format optional | Geräteschlüssel im MSO und Geräteauthentifizierung, verpflichtend |
| Vertrauen in den Aussteller | Ausstellerschlüssel, zum Beispiel über x5c oder Aussteller-Metadaten | Zertifikatskette des Ausstellers bis zu einer ausstellenden Behörde |
| Präsentation in der EUDI-Wallet | nur aus der Ferne (OpenID4VP + HAIP) | vor Ort (NFC, BLE, QR) und aus der Ferne |
| Herkunft | IETF-Arbeitsgruppe OAuth | Standard für den mobilen Führerschein |
Für einen Verifizierer bedeutet die Unterstützung der Wallet, beide Formate zu akzeptieren, in der Praxis also eine Bibliothek für jedes: Das Parsen von mdoc und die COSE-Prüfung baust du besser nicht selbst.
Häufige Fehler
- Das dekodierte JSON statt des base64url-Strings hashen. Dann passt kein einziger Digest; das RFC-Beispiel oben ist der Test.
- Kurze oder wiederverwendete Salts. Damit werden verborgene Werte erratbar oder über Claims hinweg verkettbar.
- Ein fehlendes KB-JWT als „Key Binding nicht nötig“ behandeln. Die Richtlinie des Verifizierers entscheidet, nicht die Präsentation.
audundnonceim Key-Binding-JWT nicht prüfen. Dann kann jede Präsentation, die ein Inhaber jemals erstellt hat, dir gegenüber erneut vorgelegt werden.- Grobe Disclosures. Eine Disclosure pro Objekt legt das ganze Objekt offen; gestalte die Struktur nach den Fragen, die Verifizierer stellen werden.
statusignorieren. Ein Nachweis kann signiert, nicht abgelaufen und zugleich widerrufen sein.
Quellen
- RFC 9901: Selective Disclosure for JSON Web Tokens, §4, §6, §7, §9
- draft-ietf-oauth-sd-jwt-vc, SD-JWT-based Verifiable Credentials, Version -19
- draft-ietf-oauth-status-list, Token Status List
- Verordnung (EU) 2024/1183 und Durchführungsverordnung (EU) 2024/2977
- EUDI Wallet Architecture and Reference Framework, v3.0.0, Kapitel 5; PID Rulebook
- OpenID4VP 1.0, OpenID4VCI 1.0, HAIP 1.0
- @sd-jwt/sd-jwt-vc und @auth0/mdl