SD-JWT VC frente a mdoc es la cuestión de formato que hay detrás de las billeteras de identidad digital, los permisos de conducir móviles y la cartera europea de identidad digital (EU Digital Identity Wallet). Un SD-JWT (RFC 9901) es un JWT cuyos claims se sustituyen por resúmenes con sal, y los valores correspondientes viajan junto a él como divulgaciones independientes; SD-JWT VC añade tipos de credencial y el descubrimiento de la clave del emisor. Un mdoc (ISO/IEC 18013-5) es un documento CBOR cuyos elementos de datos se recogen como resúmenes en un Mobile Security Object firmado con COSE. Ambos revelan solo los atributos que elige el titular, y ambos demuestran que quien presenta la credencial posee la clave del dispositivo.
De un vistazo
| SD-JWT VC | mdoc (ISO/IEC 18013-5) | |
|---|---|---|
| Especificación base | RFC 9901 SD-JWT (noviembre de 2025); SD-JWT VC (borrador del IETF, enviado al IESG) | ISO/IEC 18013-5:2021; presentación en línea en ISO/IEC TS 18013-7:2025 |
| Codificación | JSON, base64url, divulgaciones separadas por ~ | CBOR (RFC 8949) |
| Firma del emisor | JWS, normalmente ES256 | COSE_Sign1 sobre el Mobile Security Object |
| Divulgación selectiva | Resumen con sal por claim en _sd; objetos anidados y elementos de arrays | Resumen con sal por elemento de datos y por espacio de nombres; listas planas de elementos |
| Vinculación al titular | Clave en cnf; JWT de Key Binding con aud, nonce, sd_hash | Clave del dispositivo en el MSO; firma o MAC del dispositivo sobre la transcripción de la sesión |
| Tipo de credencial | URI vct con metadatos del tipo | docType, por ejemplo org.iso.18013.5.1.mDL |
| Descubrimiento de la clave del emisor | .well-known/jwt-vc-issuer, x5c o DID | x5chain con una raíz IACA (listas de confianza nacionales) |
| Revocación | Token Status List (borrador del IETF en la cola del RFC Editor) | Validez corta; listas de estados en los perfiles más recientes |
| Presentación presencial | No definida | Conexión con el dispositivo (device engagement) por NFC, BLE y QR |
| Presentación en línea | OpenID4VP 1.0 (dc+sd-jwt) | OpenID4VP 1.0 (mso_mdoc), ISO 18013-7, anexo C, Digital Credentials API |
| Medido más abajo: credencial emitida y presentación con uno de cinco claims | 1112 bytes; 1159 bytes | 1046 bytes; 895 bytes (1194 en base64url) |
Cómo funciona la divulgación selectiva en cada uno
En SD-JWT, el emisor convierte cada claim en una divulgación, la codificación base64url de [salt, name, value], y firma solo los resúmenes SHA-256 del array _sd del JWT. El titular envía el JWT y las divulgaciones que elige, separados por ~. El verificador calcula el hash de cada divulgación y comprueba que el resumen está en _sd; los claims sin divulgación permanecen ocultos, y la sal impide adivinarlos a partir del resumen. Un JWT de Key Binding (vinculación de clave, typ: kb+jwt) firmado con la clave del titular lleva el aud y el nonce del verificador y un sd_hash calculado sobre la presentación exacta.
En mdoc, cada elemento de datos es un IssuerSignedItem (ID de resumen, sal aleatoria, identificador, valor) codificado en CBOR y envuelto en la etiqueta 24. El Mobile Security Object enumera los resúmenes SHA-256 por espacio de nombres, la clave pública del dispositivo y el período de validez, y el emisor lo firma con COSE. El titular devuelve solo los elementos seleccionados junto con el MSO firmado por el emisor, además de una firma del dispositivo sobre una transcripción de la sesión; en OpenID4VP, esa transcripción contiene un hash del ID de cliente, el nonce y la URI de respuesta del verificador.
El script siguiente construye ambos formatos con los mismos cinco atributos, los firma con claves P-256 y presenta solo age_over_18. Antes comprueba el cálculo del resumen de SD-JWT con el ejemplo del 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)
El resumen coincide con el valor que figura en el RFC 9901. Los tamaños proceden de un script mínimo sin certificados del emisor: las credenciales reales añaden una cadena de certificados x5c o x5chain (a menudo de 1 a 2 KB), información de estado y más atributos, lo que afecta a ambos formatos más o menos por igual. Aun así, el patrón se mantiene. Una presentación SD-JWT es el JWT completo del emisor con todos los resúmenes, más una divulgación y un JWT de vinculación de clave, mientras que una presentación mdoc descarta los elementos no seleccionados y conserva el MSO, de modo que las presentaciones mdoc se reducen a medida que quedan más atributos ocultos.
Dónde se usa cada uno
El reglamento europeo de identidad digital obliga a los Estados miembros a ofrecer billeteras a más tardar en diciembre de 2026, y a las partes usuarias privadas (relying parties) que deben aplicar autenticación reforzada de usuario, como los bancos, a aceptarlas a más tardar en diciembre de 2027; el Architecture and Reference Framework (marco de arquitectura y referencia) exige a los proveedores emitir los datos de identificación personal tanto en mdoc como en SD-JWT VC. Los permisos de conducir móviles siguen la norma ISO/IEC 18013-5 en todo el mundo, y la TSA estadounidense y muchos estados de EE. UU. los aceptan en los controles.
En la web, Chrome 141 y Safari 26 incorporaron en 2025 la Digital Credentials API del W3C, que permite a una página solicitar una credencial a la billetera de la plataforma. Chrome acepta solicitudes OpenID4VP, así que se pueden pedir ambos formatos; Safari solo admite el protocolo del anexo C de ISO 18013-7 (org-iso-mdoc), así que los verificadores web que quieran llegar a los usuarios de iPhone deben aceptar mdoc. OpenID4VP 1.0, versión final desde julio de 2025, define los formatos dc+sd-jwt y mso_mdoc y el lenguaje de consulta DCQL para ambos.
Diferencias prácticas al implementar
- Herramientas: SD-JWT es JSON y JOSE, así que basta cualquier biblioteca de JWT más un centenar de líneas para tratar las divulgaciones; el inspector de SD-JWT decodifica estos tokens. mdoc necesita CBOR, COSE y el modelo de datos de ISO; el inspector de mdoc y el decodificador de COSE muestran las estructuras.
- Modelo de datos: SD-JWT admite objetos anidados y elementos de array divulgables uno a uno; mdoc usa espacios de nombres planos de elementos, con identificadores definidos por ISO como
age_over_18. - No vinculabilidad: ambos reutilizan la misma firma del emisor y los mismos resúmenes en todas las presentaciones, así que varios verificadores que se pongan de acuerdo pueden vincularlas. Las billeteras lo mitigan con lotes de credenciales emitidas a la vez, cada una de las cuales se usa una sola vez.
- Confianza: mdoc se apoya en cadenas de certificados del emisor que llegan hasta raíces nacionales (IACA), lo que encaja con los emisores públicos. SD-JWT VC admite tanto el descubrimiento de claves basado en la web como X.509.
- Estado de los estándares: RFC 9901, ISO/IEC 18013-5 y OpenID4VP 1.0 son definitivos. SD-JWT VC y la Token Status List son borradores del IETF en sus últimas fases (octubre de 2026), así que fija la versión del borrador que implementes.
Cuándo elegir cada uno
- Identidad oficial, permisos de conducir, verificación presencial de la edad: mdoc.
- Verificación web que deba funcionar hoy en iPhone: mdoc mediante la Digital Credentials API.
- Datos de identificación personal de la EUDI Wallet: ambos, como exige el marco de referencia.
- Títulos académicos, licencias profesionales, credenciales de empleado o de socio emitidas en línea: SD-JWT VC.
- Sistemas ya basados en OAuth y OpenID Connect: SD-JWT VC mediante OpenID4VCI y OpenID4VP.
- Claims JSON firmados sin divulgación selectiva: basta un JWT simple o una credencial verificable.
Notas de migración
- Implementa OpenID4VP 1.0 con DCQL, que solicita cualquiera de los dos formatos con la misma estructura de consulta.
- Verifica todo lo que exigen los estándares: la firma del emisor y la cadena de confianza, los resúmenes de cada elemento divulgado, la validez o el estado, y la vinculación al titular, comprobada con tu propio nonce y tu propia audiencia; omitir la vinculación convierte la credencial en un token de portador que cualquiera puede volver a presentar.
- Fija las versiones de los borradores: anota qué borradores de SD-JWT VC y de la lista de estados implementas, y sigue sus cambios hasta que se conviertan en RFC.
- Pide lo mínimo: solicita
age_over_18en lugar de la fecha de nacimiento cuando eso es lo que quieres saber; ambos formatos lo admiten. - Planifica la gestión de certificados: los verificadores de mdoc necesitan listas de confianza nacionales para las raíces IACA; los de SD-JWT VC necesitan los metadatos del emisor o la validación de certificados.
Referencias
- RFC 9901, divulgación selectiva para JWT
- Credenciales verificables basadas en SD-JWT (borrador del IETF)
- Token Status List (borrador del IETF)
- ISO/IEC 18013-5:2021, permiso de conducir móvil
- OpenID for Verifiable Presentations 1.0 (apéndices B.2, mdoc, y B.3, SD-JWT VC)
- W3C Digital Credentials API
- Chrome: lanzamiento de la Digital Credentials API
- Architecture and Reference Framework de la EUDI Wallet