SD-JWT VC vs mdoc, c’est la question de format au cœur des portefeuilles d’identité numérique, des permis de conduire mobiles et du portefeuille européen d’identité numérique (EU Digital Identity Wallet). Un SD-JWT (RFC 9901) est un JWT dont les claims sont remplacés par des condensats salés, les valeurs correspondantes étant envoyées à côté sous forme de divulgations distinctes ; SD-JWT VC y ajoute des types d’attestation et la découverte des clés de l’émetteur. Un mdoc (ISO/IEC 18013-5) est un document CBOR dont les éléments de données sont hachés et leurs condensats réunis dans un Mobile Security Object signé avec COSE. Tous deux ne révèlent que les attributs choisis par le détenteur, et tous deux prouvent que celui qui présente l’attestation détient la clé de l’appareil.
Vue d’ensemble
| SD-JWT VC | mdoc (ISO/IEC 18013-5) | |
|---|---|---|
| Spécification de base | RFC 9901 SD-JWT (novembre 2025) ; SD-JWT VC (Internet-Draft de l’IETF, soumis à l’IESG) | ISO/IEC 18013-5:2021 ; présentation en ligne dans ISO/IEC TS 18013-7:2025 |
| Encodage | JSON, base64url, divulgations séparées par ~ | CBOR (RFC 8949) |
| Signature de l’émetteur | JWS, généralement ES256 | COSE_Sign1 sur le Mobile Security Object |
| Divulgation sélective | Condensat salé par claim dans _sd ; objets imbriqués et éléments de tableau | Condensat salé par élément de données, par espace de noms ; listes d’éléments à plat |
| Liaison au détenteur | Clé dans cnf ; Key Binding JWT avec aud, nonce, sd_hash | Clé de l’appareil dans le MSO ; signature ou MAC de l’appareil sur la transcription de session |
| Type d’attestation | URI vct avec métadonnées de type | docType, par exemple org.iso.18013.5.1.mDL |
| Découverte des clés de l’émetteur | .well-known/jwt-vc-issuer, x5c ou DID | x5chain avec une racine IACA (listes de confiance nationales) |
| Révocation | Token Status List (Internet-Draft de l’IETF dans la file d’attente du RFC Editor) | Validité courte, listes de statut dans les profils plus récents |
| Présentation sur place | Non définie | Device engagement par NFC, BLE et code QR |
| Présentation en ligne | OpenID4VP 1.0 (dc+sd-jwt) | OpenID4VP 1.0 (mso_mdoc), ISO 18013-7 annexe C, Digital Credentials API |
| Mesures ci-dessous : à l’émission, puis avec un claim sur cinq présenté | 1112 octets ; 1159 octets | 1046 octets ; 895 octets (1194 en base64url) |
Comment fonctionne la divulgation sélective dans chaque format
Dans SD-JWT, l’émetteur transforme chaque claim en une divulgation (disclosure), c’est-à-dire l’encodage base64url de [salt, name, value], et ne signe que les condensats SHA-256 placés dans le tableau _sd du JWT. Le détenteur envoie le JWT et les divulgations de son choix, séparés par ~. Le vérificateur hache chaque divulgation et vérifie que le condensat figure dans _sd ; les claims sans divulgation restent cachés, et le sel empêche de les deviner à partir du condensat. Un Key Binding JWT (typ: kb+jwt), signé par la clé du détenteur, porte les valeurs aud et nonce du vérificateur ainsi qu’un sd_hash calculé sur la présentation exacte.
Dans mdoc, chaque élément de données est un IssuerSignedItem (ID de condensat, sel aléatoire, identifiant, valeur) encodé en CBOR et encapsulé dans le tag 24. Le Mobile Security Object liste les condensats SHA-256 par espace de noms, la clé publique de l’appareil et la période de validité, et l’émetteur le signe avec COSE. Le détenteur ne renvoie que les éléments sélectionnés avec le MSO signé par l’émetteur, plus une signature de l’appareil sur une transcription de session ; pour OpenID4VP, cette transcription contient un condensat de l’ID client, du nonce et de l’URI de réponse du vérificateur.
Le script ci-dessous construit les deux formats avec les cinq mêmes attributs, les signe avec des clés P-256 et ne présente que age_over_18. Il vérifie d’abord le calcul du condensat SD-JWT à l’aide de l’exemple du §4.2.3 de la RFC 9901 (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)
Le condensat correspond à la valeur indiquée dans la RFC 9901. Les tailles proviennent d’une ébauche minimale sans certificats d’émetteur : les attestations réelles ajoutent une chaîne de certificats x5c ou x5chain (souvent 1 à 2 Ko), des informations de statut et davantage d’attributs, ce qui touche les deux formats à peu près de la même manière. La tendance reste toutefois valable. Une présentation SD-JWT contient le JWT complet de l’émetteur avec tous les condensats, plus une divulgation et un Key Binding JWT, alors qu’une présentation mdoc écarte les éléments non sélectionnés et conserve le MSO : les présentations mdoc sont donc d’autant plus petites que davantage d’attributs restent cachés.
Où chacun est utilisé
Le règlement européen sur l’identité numérique impose aux États membres de fournir des portefeuilles d’ici décembre 2026, et aux parties utilisatrices privées tenues d’utiliser une authentification forte des utilisateurs, comme les banques, de les accepter d’ici décembre 2027 ; le cadre d’architecture et de référence (Architecture and Reference Framework) impose aux fournisseurs d’émettre les données d’identification personnelle à la fois en mdoc et en SD-JWT VC. Les permis de conduire mobiles suivent la norme ISO/IEC 18013-5 dans le monde entier, et la TSA américaine ainsi que de nombreux États américains les acceptent aux points de contrôle.
Sur le web, Chrome 141 et Safari 26 ont livré en 2025 la Digital Credentials API du W3C, qui permet à une page de demander une attestation au portefeuille de la plateforme. Chrome accepte les requêtes OpenID4VP, si bien que les deux formats peuvent être demandés ; Safari ne prend en charge que le protocole de l’annexe C d’ISO 18013-7 (org-iso-mdoc), donc les vérificateurs web qui veulent toucher les utilisateurs d’iPhone doivent accepter mdoc. OpenID4VP 1.0, finalisé depuis juillet 2025, définit les formats dc+sd-jwt et mso_mdoc ainsi que le langage de requête DCQL pour les deux.
Différences pratiques pour l’implémentation
- Outillage : SD-JWT repose sur JSON et JOSE, donc n’importe quelle bibliothèque JWT, plus une centaine de lignes de gestion des divulgations, suffit ; l’inspecteur SD-JWT les décode. mdoc nécessite CBOR, COSE et le modèle de données ISO ; l’inspecteur mdoc et le décodeur COSE en montrent les structures.
- Modèle de données : SD-JWT prend en charge les objets imbriqués et des éléments de tableau divulgables individuellement ; mdoc utilise des espaces de noms d’éléments à plat, avec des identifiants définis par l’ISO comme
age_over_18. - Non-chaînabilité : tous deux réutilisent la même signature d’émetteur et les mêmes condensats d’une présentation à l’autre, si bien que des vérificateurs de connivence peuvent les relier entre elles. Les portefeuilles atténuent ce risque en faisant émettre des attestations par lots et en n’utilisant chacune qu’une seule fois.
- Confiance : mdoc s’appuie sur des chaînes de certificats d’émetteur qui remontent jusqu’à des racines nationales (IACA), ce qui convient aux émetteurs publics. SD-JWT VC prend en charge aussi bien la découverte de clés par le web que X.509.
- État de la normalisation : la RFC 9901, la norme ISO/IEC 18013-5 et OpenID4VP 1.0 sont finalisés. SD-JWT VC et la Token Status List sont des Internet-Drafts de l’IETF en phase finale (octobre 2026) : épinglez donc la version de l’Internet-Draft que vous implémentez.
Quand choisir l’un ou l’autre
- Identité officielle, permis de conduire, vérification de l’âge sur place : mdoc.
- Vérification sur le web qui doit fonctionner dès aujourd’hui sur iPhone : mdoc via la Digital Credentials API.
- Données d’identification personnelle du portefeuille EUDI : les deux, comme l’exige le cadre de référence.
- Diplômes, autorisations d’exercer, attestations d’employé ou d’adhésion émises en ligne : SD-JWT VC.
- Systèmes déjà construits sur OAuth et OpenID Connect : SD-JWT VC via OpenID4VCI et OpenID4VP.
- Claims JSON signés sans divulgation sélective : un simple JWT ou une attestation vérifiable suffit.
Notes de migration
- Implémentez OpenID4VP 1.0 avec DCQL, qui demande l’un ou l’autre format avec la même structure de requête.
- Vérifiez tout ce qu’exigent les normes : signature et chaîne de confiance de l’émetteur, condensats de chaque élément divulgué, validité ou statut, et liaison au détenteur par rapport à votre propre nonce et à votre audience ; sans la vérification de cette liaison, une attestation devient un jeton porteur que n’importe qui peut rejouer.
- Épinglez les versions des Internet-Drafts : notez quelles versions des Internet-Drafts SD-JWT VC et Token Status List vous implémentez, et suivez leurs évolutions jusqu’à ce qu’ils deviennent des RFC.
- Demandez le minimum : demandez
age_over_18plutôt qu’une date de naissance quand c’est la seule question posée ; les deux formats le permettent. - Planifiez la gestion des certificats : les vérificateurs mdoc ont besoin des listes de confiance nationales pour les racines IACA ; les vérificateurs SD-JWT VC ont besoin des métadonnées de l’émetteur ou d’une validation de certificat.
Références
- RFC 9901, divulgation sélective pour les JWT
- SD-JWT-based Verifiable Credentials (Internet-Draft de l’IETF)
- Token Status List (Internet-Draft de l’IETF)
- ISO/IEC 18013-5:2021, permis de conduire mobile
- OpenID for Verifiable Presentations 1.0 (annexe B.2 mdoc, B.3 SD-JWT VC)
- W3C Digital Credentials API
- Chrome : lancement de la Digital Credentials API
- Cadre d’architecture et de référence de l’EUDI Wallet