Bei SD-JWT VC vs. mdoc geht es um die Formatfrage hinter digitalen Identitäts-Wallets, mobilen Führerscheinen und der EU Digital Identity Wallet. Ein SD-JWT (RFC 9901) ist ein JWT, dessen Claims durch gesalzene Digests ersetzt sind; die zugehörigen Werte werden daneben als separate Disclosures mitgeschickt. SD-JWT VC ergänzt Credential-Typen und die Ermittlung von Ausstellerschlüsseln. Ein mdoc (ISO/IEC 18013-5) ist ein CBOR-Dokument, dessen Datenelemente als Digests in ein per COSE signiertes Mobile Security Object eingehen. Beide geben nur die Attribute preis, die der Inhaber auswählt, und beide beweisen, dass die vorlegende Partei den Geräteschlüssel besitzt.
Auf einen Blick
| SD-JWT VC | mdoc (ISO/IEC 18013-5) | |
|---|---|---|
| Kernspezifikation | RFC 9901 SD-JWT (November 2025); SD-JWT VC (IETF-Entwurf, bei der IESG eingereicht) | ISO/IEC 18013-5:2021; Online-Präsentation in ISO/IEC TS 18013-7:2025 |
| Kodierung | JSON, base64url, durch ~ getrennte Disclosures | CBOR (RFC 8949) |
| Ausstellersignatur | JWS, meist ES256 | COSE_Sign1 über das Mobile Security Object |
| Selektive Offenlegung | Gesalzener Digest pro Claim in _sd; verschachtelte Objekte und Array-Elemente | Gesalzener Digest pro Datenelement, pro Namespace; flache Elementlisten |
| Holder Binding | Schlüssel in cnf; Key-Binding-JWT mit aud, nonce, sd_hash | Geräteschlüssel im MSO; Gerätesignatur oder MAC über das Sitzungstranskript |
| Credential-Typ | URI in vct mit Typ-Metadaten | docType, zum Beispiel org.iso.18013.5.1.mDL |
| Ermittlung des Ausstellerschlüssels | .well-known/jwt-vc-issuer, x5c oder DID | x5chain mit einem IACA-Root-Zertifikat (Vertrauenslisten der Länder) |
| Widerruf | Token Status List (IETF-Entwurf in der Warteschlange des RFC Editor) | Kurze Gültigkeit, Statuslisten in neueren Profilen |
| Präsentation vor Ort | Nicht definiert | Device Engagement per NFC, BLE und QR |
| Online-Präsentation | OpenID4VP 1.0 (dc+sd-jwt) | OpenID4VP 1.0 (mso_mdoc), ISO 18013-7 Anhang C, Digital Credentials API |
| Unten gemessen: ausgestellt, dann einer von fünf Claims gezeigt | 1.112 Byte; 1.159 Byte | 1.046 Byte; 895 Byte (1.194 als base64url) |
So funktioniert die selektive Offenlegung in beiden Formaten
Bei SD-JWT macht der Aussteller aus jedem Claim eine Disclosure, die base64url-Kodierung von [salt, name, value], und signiert nur die SHA-256-Digests im Array _sd des JWT. Der Inhaber sendet das JWT und die Disclosures, die er auswählt, getrennt durch ~. Der Verifizierer hasht jede Disclosure und prüft, ob der Digest in _sd steht; Claims ohne Disclosure bleiben verborgen, und das Salt verhindert, dass sich die Claims aus dem Digest erraten lassen. Ein Key-Binding-JWT (typ: kb+jwt), mit dem Schlüssel des Inhabers signiert, trägt aud und nonce des Verifizierers sowie einen sd_hash über genau diese Präsentation.
Bei mdoc ist jedes Datenelement ein IssuerSignedItem (Digest-ID, zufälliges Salt, Bezeichner, Wert), in CBOR kodiert und in Tag 24 eingebettet. Das Mobile Security Object listet die SHA-256-Digests pro Namespace, den öffentlichen Schlüssel des Geräts und den Gültigkeitszeitraum auf, und der Aussteller signiert es mit COSE. Der Inhaber gibt nur die ausgewählten Elemente mit dem vom Aussteller signierten MSO zurück, dazu eine Gerätesignatur über ein Sitzungstranskript; bei OpenID4VP enthält dieses Transkript einen Hash aus Client-ID, Nonce und Response-URI des Verifizierers.
Das folgende Skript erstellt beide Formate mit denselben fünf Attributen, signiert sie mit P-256-Schlüsseln und präsentiert nur age_over_18. Zuerst prüft es die Berechnung des SD-JWT-Digests am Beispiel aus RFC 9901 §4.2.3 (Node.js 26, jose 6.2.13, cbor-x 1.6.0).
import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
import { SignJWT, exportJWK } from "jose";
import { Encoder, Tag } from "cbor-x";
const sha256 = (b) => createHash("sha256").update(b).digest();
const b64u = (b) => Buffer.from(b).toString("base64url");
const cbor = new Encoder({ useRecords: false, useTag259ForMaps: false, variableMapSize: true, tagUint8Array: false });
const tag24 = (v) => new Tag(cbor.encode(v), 24);
// RFC 9901 §4.2.3 example: digest of the family_name Disclosure
const rfcDisclosure = "WyJfMjZiYzRMVC1hYzZxMktJNmNCVzVlcyIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0";
console.log("RFC 9901 example digest:", b64u(sha256(rfcDisclosure)));
const claims = { given_name: "Erika", family_name: "Mustermann", birth_date: "1984-01-26", age_over_18: true, nationality: "DE" };
const issuer = generateKeyPairSync("ec", { namedCurve: "P-256" });
const holder = generateKeyPairSync("ec", { namedCurve: "P-256" });
const holderJwk = await exportJWK(holder.publicKey);
const nonce = b64u(randomBytes(16));
// SD-JWT VC: one salted Disclosure per claim, their digests signed in _sd
const disclosures = Object.entries(claims).map(([k, v]) => b64u(JSON.stringify([b64u(randomBytes(16)), k, v])));
const jwt = await new SignJWT({
iss: "https://issuer.example", vct: "https://credentials.example/identity_credential",
_sd: disclosures.map((d) => b64u(sha256(d))), _sd_alg: "sha-256", cnf: { jwk: holderJwk },
}).setProtectedHeader({ alg: "ES256", typ: "dc+sd-jwt" }).sign(issuer.privateKey);
const issued = `${jwt}~${disclosures.join("~")}~`;
const shown = `${jwt}~${disclosures[3]}~`; // reveal age_over_18 only
const kbJwt = await new SignJWT({ aud: "https://verifier.example", nonce, sd_hash: b64u(sha256(shown)) })
.setProtectedHeader({ alg: "ES256", typ: "kb+jwt" }).setIssuedAt().sign(holder.privateKey);
console.log(`SD-JWT VC: issued ${issued.length} bytes; presentation of 1 of 5 claims ${(shown + kbJwt).length} bytes`);
// mdoc (ISO/IEC 18013-5): one salted IssuerSignedItem per element, digests signed in the MSO
const ns = "org.iso.18013.5.1";
const docType = "org.iso.18013.5.1.mDL";
const items = Object.entries(claims).map(([k, v], i) =>
tag24({ digestID: i, random: randomBytes(16), elementIdentifier: k, elementValue: v }));
const coseSign1 = (key, payload, detached) => {
const prot = cbor.encode(new Map([[1, -7]])); // alg: ES256
const toBeSigned = cbor.encode(["Signature1", prot, Buffer.alloc(0), payload]);
return [prot, {}, detached ? null : payload, sign("sha256", toBeSigned, { key, dsaEncoding: "ieee-p1363" })];
};
const mso = {
version: "1.0", digestAlgorithm: "SHA-256", docType,
valueDigests: { [ns]: new Map(items.map((t, i) => [i, sha256(cbor.encode(t))])) },
deviceKeyInfo: { deviceKey: new Map([[1, 2], [-1, 1], [-2, Buffer.from(holderJwk.x, "base64url")], [-3, Buffer.from(holderJwk.y, "base64url")]]) },
validityInfo: { signed: new Tag("2026-10-20T00:00:00Z", 0), validFrom: new Tag("2026-10-20T00:00:00Z", 0), validUntil: new Tag("2027-10-20T00:00:00Z", 0) },
};
const issuerAuth = coseSign1(issuer.privateKey, cbor.encode(tag24(mso)));
const issuedMdoc = cbor.encode({ nameSpaces: { [ns]: items }, issuerAuth });
// Presentation over OpenID4VP: DeviceResponse signed by the device key over the session transcript
const handoverInfo = cbor.encode(["https://verifier.example", nonce, null, "https://verifier.example/response"]);
const transcript = [null, null, ["OpenID4VPHandover", sha256(handoverInfo)]];
const deviceNameSpaces = tag24({});
const deviceAuth = cbor.encode(tag24(["DeviceAuthentication", transcript, docType, deviceNameSpaces]));
const response = cbor.encode({
version: "1.0", status: 0,
documents: [{ docType, issuerSigned: { nameSpaces: { [ns]: [items[3]] }, issuerAuth },
deviceSigned: { nameSpaces: deviceNameSpaces, deviceAuth: { deviceSignature: coseSign1(holder.privateKey, deviceAuth, true) } } }],
});
console.log(`mdoc: issued ${issuedMdoc.length} bytes; presentation of 1 of 5 elements ${response.length} bytes (${b64u(response).length} as base64url)`);
RFC 9901 example digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
SD-JWT VC: issued 1112 bytes; presentation of 1 of 5 claims 1159 bytes
mdoc: issued 1046 bytes; presentation of 1 of 5 elements 895 bytes (1194 as base64url)
Der Digest stimmt mit dem Wert überein, den RFC 9901 abdruckt. Die Größen stammen aus einer minimalen Skizze ohne Ausstellerzertifikate: Echte Credentials bringen eine Zertifikatskette in x5c oder x5chain (oft 1–2 KB), Statusinformationen und weitere Attribute mit, was beide Formate etwa gleich stark betrifft. Das Muster bleibt aber bestehen. Eine SD-JWT-Präsentation ist das vollständige JWT des Ausstellers mit allen Digests plus einer Disclosure und einem Key-Binding-JWT, während eine mdoc-Präsentation die nicht ausgewählten Elemente weglässt und das MSO behält; mdoc-Präsentationen werden also kleiner, je mehr Attribute verborgen bleiben.
Wo welches Format eingesetzt wird
Die Verordnung über die europäische digitale Identität verpflichtet die Mitgliedstaaten, bis Dezember 2026 Wallets bereitzustellen, und private vertrauende Parteien, die eine starke Nutzerauthentifizierung verwenden müssen, etwa Banken, sie bis Dezember 2027 zu akzeptieren; das Architecture and Reference Framework verlangt von den Anbietern, Personenidentifizierungsdaten sowohl als mdoc als auch als SD-JWT VC auszustellen. Mobile Führerscheine folgen weltweit ISO/IEC 18013-5, und die US-amerikanische TSA sowie viele US-Bundesstaaten akzeptieren sie an Kontrollpunkten.
Im Web haben Chrome 141 und Safari 26 im Jahr 2025 die W3C Digital Credentials API ausgeliefert, mit der eine Seite ein Credential aus der Wallet der Plattform anfordern kann. Chrome akzeptiert OpenID4VP-Anfragen, sodass sich beide Formate anfordern lassen; Safari unterstützt nur das Protokoll aus ISO 18013-7 Anhang C (org-iso-mdoc), daher müssen Web-Verifizierer, die iPhone-Nutzer erreichen wollen, mdoc akzeptieren. OpenID4VP 1.0, final seit Juli 2025, definiert die Formate dc+sd-jwt und mso_mdoc sowie für beide die Abfragesprache DCQL.
Praktische Unterschiede bei der Implementierung
- Tooling: SD-JWT ist JSON und JOSE, daher genügt jede JWT-Bibliothek plus etwa hundert Zeilen für die Verarbeitung der Disclosures; der SD-JWT-Inspektor dekodiert sie. mdoc braucht CBOR, COSE und das Datenmodell der ISO; der mdoc-Inspektor und der COSE-Decoder zeigen die Strukturen.
- Datenmodell: SD-JWT unterstützt verschachtelte Objekte und einzeln offenlegbare Array-Elemente; mdoc verwendet flache Namespaces aus Elementen mit von der ISO definierten Bezeichnern wie
age_over_18. - Unverkettbarkeit: Beide verwenden dieselbe Ausstellersignatur und dieselben Digests in allen Präsentationen wieder, sodass Verifizierer, die zusammenarbeiten, die Präsentationen miteinander verketten können. Wallets mildern das ab, indem Credentials in Stapeln ausgestellt und jeweils nur einmal verwendet werden.
- Vertrauen: mdoc stützt sich auf Zertifikatsketten der Aussteller bis zu den Root-Zertifikaten der Länder (IACA), was zu staatlichen Ausstellern passt. SD-JWT VC unterstützt webbasierte Schlüsselermittlung und X.509 gleichermaßen.
- Stand der Standards: RFC 9901, ISO/IEC 18013-5 und OpenID4VP 1.0 sind final. SD-JWT VC und die Token Status List sind IETF-Entwürfe in der Endphase (Oktober 2026); lege daher die Entwurfsversion fest, die du implementierst.
Wann welches Format passt
- Staatliche Identitätsnachweise, Führerscheine, Alterskontrollen vor Ort: mdoc.
- Prüfung im Web, die heute schon auf iPhones funktionieren muss: mdoc über die Digital Credentials API.
- Personenidentifizierungsdaten für die EUDI-Wallet: beide, wie es das Reference Framework verlangt.
- Online ausgestellte Diplome, Berufszulassungen, Mitarbeiter- oder Mitgliedsnachweise: SD-JWT VC.
- Systeme, die bereits auf OAuth und OpenID Connect aufbauen: SD-JWT VC über OpenID4VCI und OpenID4VP.
- Signierte JSON-Claims ohne selektive Offenlegung: Ein einfaches JWT oder ein Verifiable Credential genügt.
Hinweise zur Migration
- Implementiere OpenID4VP 1.0 mit DCQL, das beide Formate mit derselben Abfragestruktur anfordert.
- Prüfe alles, was die Standards verlangen: Ausstellersignatur und Vertrauenskette, die Digests aller offengelegten Elemente, Gültigkeit oder Status und das Holder Binding gegen deine eigene Nonce und Audience; ohne Prüfung des Bindings wird aus einem Credential ein Bearer Token, das jeder erneut vorlegen kann.
- Lege Entwurfsversionen fest: Dokumentiere, welche Entwürfe von SD-JWT VC und der Status List du implementierst, und verfolge Änderungen, bis daraus RFCs werden.
- Fordere das Minimum an: Frag nach
age_over_18statt nach einem Geburtsdatum, wenn genau das die Frage ist; beide Formate unterstützen das. - Plane das Zertifikatsmanagement: mdoc-Verifizierer brauchen die Vertrauenslisten der Länder für die IACA-Root-Zertifikate; Verifizierer für SD-JWT VC brauchen Ausstellermetadaten oder eine Zertifikatsprüfung.
Quellen
- RFC 9901, Selective Disclosure für JWTs
- Verifiable Credentials auf Basis von SD-JWT (IETF-Entwurf)
- Token Status List (IETF-Entwurf)
- ISO/IEC 18013-5:2021, mobiler Führerschein
- OpenID for Verifiable Presentations 1.0 (Anhang B.2 mdoc, B.3 SD-JWT VC)
- W3C Digital Credentials API
- Chrome: Digital Credentials API ausgeliefert
- Architecture and Reference Framework der EUDI-Wallet