En bref. SD-JWT (RFC 9901, novembre 2025) remplace chaque claim masquable d’un JWT signé par le condensat d’une divulgation (disclosure) : un petit tableau JSON encodé en base64url qui contient un sel aléatoire, le nom du claim et sa valeur. L’émetteur signe les condensats ; le détenteur envoie ensuite le JWT signé accompagné des seules divulgations qu’il choisit, reliées par ~. Un vérificateur recalcule les condensats et vérifie que chacun figure dans la charge utile signée. Un JWT de liaison de clé (key binding JWT, typ: kb+jwt), signé par une clé désignée dans l’attestation, lie une présentation à un seul vérificateur (aud), à une seule requête (nonce) et exactement à ces divulgations (sd_hash). SD-JWT VC ajoute la couche d’attestation : typ: dc+sd-jwt, un type d’attestation dans vct, le statut et des métadonnées de type. C’est encore un Internet-Draft, mais c’est l’un des deux formats d’attestation que tout portefeuille européen d’identité numérique (EU Digital Identity Wallet) doit prendre en charge, aux côtés de mdoc (ISO/IEC 18013-5).
Divulgations et condensats
Pour une propriété d’objet, une divulgation est le tableau [salt, name, value] ; pour un élément de tableau, c’est [salt, value] (§4.2 de la RFC 9901). Elle est encodée en base64url sous forme de JSON UTF-8, et le condensat est le SHA-256, en base64url, de cette chaîne encodée, et non du JSON (§4.2.3). Comme le condensat porte sur les octets exacts choisis par l’émetteur, aucune canonicalisation JSON n’est nécessaire : le même claim encodé avec d’autres espaces est une autre divulgation, avec un autre condensat. Les deux points, vérifiés sur l’exemple même de la RFC :
RFC 9901 §4.2.3 digest: X9yH0Ajrdm1Oij4tWso9UzzKJvPoDxwmuEcO3XAdRC0
same claim, no spaces: TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM
L’émetteur place les condensats dans un tableau _sd de la charge utile, triés ou mélangés pour que leur ordre ne révèle rien, et peut ajouter des condensats leurres qui ne correspondent à aucune divulgation (§4.2.5), afin que le nombre de claims cachés ne fuite pas. _sd_alg désigne l’algorithme de hachage ; SHA-256 est la valeur par défaut et son implémentation est obligatoire. Le sel rend les condensats impossibles à deviner : sans lui, un vérificateur pourrait hacher ["birthdate", "1984-01-26"] pour chaque date plausible et trouver la correspondance. La RFC 9901 recommande au moins 128 bits aléatoires par sel, choisis indépendamment pour chaque claim (§9.3).
Sur le réseau, un SD-JWT s’écrit <issuer-signed JWT>~<disclosure>~…~<disclosure>~, et une présentation avec liaison de clé remplace la dernière partie, vide, par le JWT de liaison de clé : …~<disclosure>~<KB-JWT> (§4). C’est au ~ final qu’un vérificateur distingue les deux.
Émetteur, portefeuille et vérificateur en 76 lignes
Le module ci-dessous implémente le cas à plat, où chaque claim masquable est une propriété de premier niveau, ce qui suffit pour voir chaque règle à l’œuvre. Les divulgations imbriquées et récursives suivent les mêmes étapes sur les objets internes (§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);
Les règles du vérificateur correspondent une à une au §7.1 de la RFC 9901 et au §7.3 : rejeter none ; vérifier l’émetteur et sa clé ; comprendre _sd_alg ; associer chaque divulgation à exactement un condensat ; rejeter les divulgations nommées _sd ou ..., ou qui entrent en collision avec un claim visible ; rejeter un condensat qui apparaît deux fois et une divulgation à laquelle rien ne fait référence ; puis vérifier la signature du JWT de liaison de clé avec la clé de cnf, ainsi que son typ, son âge, aud, nonce et sd_hash. Notez l’étape 1 du §7.3 : exiger ou non la liaison de clé relève de la politique du vérificateur, et ne dépend jamais du fait que le détenteur a envoyé ou non un KB-JWT.
Exécution et vérification croisée
Le script pilote émet une attestation d’identité avec cinq claims masquables (prénom, nom, date de naissance, adresse et seuils d’âge), ne présente que les seuils d’âge à une boutique, vérifie la présentation avec le module ci-dessus et avec @sd-jwt/sd-jwt-vc 0.23.0 de l’OpenWallet Foundation, puis tente six manipulations contre les deux :
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
Les deux vérificateurs rendent le même verdict dans tous les cas. Ce que disent les lignes :
- La divulgation sélective fonctionne. La boutique a reçu
age_equal_or_over;given_namevautundefineddans le résultat de la bibliothèque, car sa divulgation n’a jamais été envoyée. Les deux vérificateurs ont reconstitué les mêmes claims. - Un détenteur ne peut pas modifier un claim. Modifier la divulgation de l’âge a produit un condensat que l’émetteur n’a jamais signé.
- Une présentation vaut pour un seul vérificateur et une seule requête. Rejouée auprès d’une autre audience, ou envoyée avec un nonce périmé, elle a échoué.
sd_hashfige la sélection. Ajouter une divulgation après que le portefeuille a signé le JWT de liaison de clé a invalidé la présentation.- Sans la clé du portefeuille, l’attestation est inutilisable, si le vérificateur exige la liaison de clé : un JWT de liaison de clé retiré ou signé de nouveau a été rejeté.
L’exécution montre aussi une chose qui relève de la leçon de conception plutôt que de la vérification : la boutique a demandé « plus de 18 ans » et a aussi appris « pas plus de 65 ans », parce que l’émetteur a regroupé tous les seuils dans une seule divulgation. La granularité se décide à l’émission. Le §6.2 de la RFC 9901 montre la forme structurée sur un objet address : chaque propriété reçoit sa propre divulgation, et l’objet conserve un seul tableau _sd, avec un condensat par propriété. Émis de cette façon, age_equal_or_over permettrait au portefeuille de ne divulguer que "18": true.
Ce que la divulgation sélective ne cache pas
La divulgation sélective cache des valeurs, pas l’attestation. Chaque présentation d’un même SD-JWT porte la même signature d’émetteur, les mêmes condensats _sd et la même clé cnf : deux vérificateurs qui comparent leurs informations peuvent donc établir qu’ils ont vu la même attestation, même si l’un n’a reçu qu’un âge et l’autre qu’un code postal. Le §10.1 de la RFC 9901 le dit sans détour : le format « ne satisfait pas les propriétés de sécurité des attestations anonymes », et un émetteur à qui un vérificateur renvoie des présentations peut toujours reconnaître sa propre attestation. Il en va de même pour mdoc, qui repose sur la même approche par condensats salés.
La parade que décrit la RFC est l’émission par lots : l’émetteur remet au portefeuille plusieurs attestations portant les mêmes claims, et le portefeuille en utilise une différente pour chaque vérificateur, voire pour chaque session. Pour que cela fonctionne, chaque attestation du lot doit avoir ses propres sels et sa propre clé de liaison, et les claims temporels comme iat et exp doivent être rendus aléatoires ou arrondis, car des horodatages exacts permettent de relier les présentations aussi sûrement qu’un numéro de série. L’émission par lots empêche les vérificateurs de relier les présentations entre eux ; elle ne peut pas cacher une présentation à l’émetteur lui-même. Les vérifications de statut sont une fuite apparentée : un vérificateur qui interroge l’émetteur sur une attestation lui apprend où elle est utilisée. C’est pourquoi la Token Status List permet aux vérificateurs de télécharger une liste compressée couvrant de nombreuses attestations, au lieu de poser la question pour une seule.
Ce qu’ajoute SD-JWT VC
SD-JWT est un conteneur. La spécification SD-JWT-based Verifiable Credentials définit comment l’utiliser pour des attestations :
typ: dc+sd-jwt, type de médiaapplication/dc+sd-jwt. Jusqu’à la version -06, l’Internet-Draft utilisaitvc+sd-jwt; il a changé pour éviter le nomvcqu’emploie le modèle de données des attestations vérifiables du W3C (Verifiable Credentials Data Model). Les vérificateurs qui ont rencontré les premières implémentations peuvent encore voirvc+sd-jwt.vct, obligatoire : le type d’attestation, une chaîne résistante aux collisions. Les types peuvent avoir des métadonnées de type lisibles par machine, pour les schémas et l’affichage.iss,cnf(la clé du détenteur, obligatoire lorsque la liaison de clé est utilisée),status(révocation au moyen d’une Token Status List) et les claims de validité. Ces claims, ainsi quevct, ne doivent pas être rendus divulgables de façon sélective : un vérificateur doit toujours voir qui a émis l’attestation, à qui elle est liée et si elle est encore valide.
État au 20 octobre 2026 : SD-JWT VC en est à la version -19 (31 août 2026), soumise à l’IESG, et n’est pas encore une RFC. La Token Status List, qu’il utilise pour la révocation, est approuvée et se trouve dans la file d’attente du RFC Editor. La RFC 9901 elle-même est publiée.
Le portefeuille européen d’identité numérique
Le règlement eIDAS révisé (règlement (UE) 2024/1183) impose à chaque État membre de fournir au moins un portefeuille européen d’identité numérique dans les 24 mois qui suivent l’entrée en vigueur de ses principaux actes d’exécution. Ces actes, dont le règlement d’exécution (UE) 2024/2977 relatif aux données d’identification personnelle, ont été publiés le 4 décembre 2024 et sont entrés en vigueur le 24 décembre 2024, ce qui fixe l’échéance au 24 décembre 2026.
Le volet technique est décrit dans le cadre d’architecture et de référence (Architecture and Reference Framework, ARF), dont la version 3.0.0 a été publiée le 23 juillet 2026 (l’ARF lui-même est informatif ; le texte contraignant, ce sont les actes d’exécution). Son chapitre 5 indique :
- Les portefeuilles prennent en charge deux formats d’attestation : mdoc (ISO/IEC 18013-5) et SD-JWT VC. Le W3C VCDM 2.0 est facultatif, et réservé aux attestations non qualifiées.
- Les attestations mdoc sont présentées en proximité selon ISO/IEC 18013-5, et à distance selon ISO/IEC 18013-7 ou OpenID for Verifiable Presentations avec le profil HAIP. Les attestations SD-JWT VC ne sont présentées qu’à distance, via OpenID4VP et HAIP ; elles « ne peuvent pas être utilisées pour des présentations en proximité ».
- Les données d’identification personnelle (PID) utilisent le type de document mdoc
eu.europa.ec.eudi.pid.1et le type SD-JWT VCurn:eudi:pid:1, avec des types nationaux commeurn:eudi:pid:de:1dans le même espace de noms (PID Rulebook).
Les protocoles OpenID qui l’entourent sont finalisés : OpenID4VP 1.0 (juillet 2025), OpenID4VCI 1.0 (septembre 2025) et HAIP 1.0 (décembre 2025), qui définit un profil de SD-JWT VC et de mdoc pour les usages à haut niveau d’assurance.
SD-JWT VC et mdoc côte à côte
L’ARF qualifie les deux mécanismes de divulgation sélective de « conceptuellement identiques » : tous deux signent des condensats salés d’attributs individuels. La différence tient à tout ce qui les entoure. La colonne mdoc reprend les noms de structures de la norme ISO, qui n’est pas librement accessible ; la bibliothèque open source @auth0/mdl expose les mêmes noms dans ses types.
| SD-JWT VC | ISO/IEC 18013-5 mdoc | |
|---|---|---|
| Encodage | JSON, JWS, base64url | CBOR, COSE |
| Attribut caché | divulgation [salt, name, value] | IssuerSignedItem avec random, digestID, elementIdentifier, elementValue |
| Emplacement des condensats | tableaux _sd dans le JWT signé par l’émetteur | valueDigests par espace de noms dans le Mobile Security Object (MSO), signé par l’émetteur |
| Liaison au détenteur | JWT de liaison de clé, facultatif dans le format | clé de l’appareil dans le MSO et authentification de l’appareil, obligatoires |
| Confiance dans l’émetteur | clé de l’émetteur, par exemple via x5c ou les métadonnées de l’émetteur | chaîne de certificats de l’émetteur jusqu’à une autorité de délivrance |
| Présentation dans le portefeuille EUDI | à distance uniquement (OpenID4VP + HAIP) | en proximité (NFC, BLE, QR) et à distance |
| Origine | groupe de travail OAuth de l’IETF | norme du permis de conduire mobile |
Pour un vérificateur, prendre en charge le portefeuille signifie accepter les deux formats, et donc, en pratique, une bibliothèque pour chacun : l’analyse de mdoc et la vérification COSE ne sont pas des choses à coder soi-même.
Erreurs fréquentes
- Hacher le JSON décodé au lieu de la chaîne base64url. Aucun condensat ne correspondra ; l’exemple de la RFC ci-dessus sert de test.
- Des sels courts ou réutilisés. Ils rendent les valeurs cachées devinables, ou permettent de les relier d’un claim à l’autre.
- Interpréter l’absence de KB-JWT comme « liaison de clé inutile ». C’est la politique du vérificateur qui décide, pas la présentation.
- Ne pas vérifier
audetnoncedans le JWT de liaison de clé. N’importe quelle présentation faite un jour par un détenteur peut alors être rejouée auprès de vous. - Des divulgations trop grossières. Une seule divulgation par objet révèle l’objet entier ; concevez la structure en fonction des questions que poseront les vérificateurs.
- Ignorer
status. Une attestation peut être à la fois signée, non expirée et révoquée.
Sources
- 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
- Règlement (UE) 2024/1183 et règlement d’exécution (UE) 2024/2977
- Cadre d’architecture et de référence de l’EUDI Wallet, v3.0.0, chapitre 5 ; PID Rulebook
- OpenID4VP 1.0, OpenID4VCI 1.0, HAIP 1.0
- @sd-jwt/sd-jwt-vc et @auth0/mdl