Skip to content
ecdsa.com

SD-JWT VC vs mdoc: the two credential formats of digital wallets

Both let a wallet reveal only the attributes a verifier asks for, signed by the issuer and bound to a key on the holder's device. One is built from JSON and JOSE, the other from CBOR and COSE.

SD-JWT VC vs mdoc is the format question behind digital identity wallets, mobile driving licences and the EU Digital Identity Wallet. An SD-JWT (RFC 9901) is a JWT whose claims are replaced by salted digests, with the matching values sent alongside as separate disclosures; SD-JWT VC adds credential types and issuer key discovery. An mdoc (ISO/IEC 18013-5) is a CBOR document whose data elements are digested into a COSE-signed Mobile Security Object. Both reveal only the attributes the holder chooses, and both prove that the presenter holds the device key.

At a glance

SD-JWT VCmdoc (ISO/IEC 18013-5)
Core specificationRFC 9901 SD-JWT (November 2025); SD-JWT VC (IETF draft, submitted to the IESG)ISO/IEC 18013-5:2021; online presentation in ISO/IEC TS 18013-7:2025
EncodingJSON, base64url, ~-separated disclosuresCBOR (RFC 8949)
Issuer signatureJWS, typically ES256COSE_Sign1 over the Mobile Security Object
Selective disclosureSalted digest per claim in _sd; nested objects and array elementsSalted digest per data element, per namespace; flat element lists
Holder bindingKey in cnf; Key Binding JWT with aud, nonce, sd_hashDevice key in the MSO; device signature or MAC over the session transcript
Credential typevct URI with type metadatadocType, for example org.iso.18013.5.1.mDL
Issuer key discovery.well-known/jwt-vc-issuer, x5c or DIDx5chain with an IACA root (country trust lists)
RevocationToken Status List (IETF draft in the RFC Editor queue)Short validity, status lists in newer profiles
In-person presentationNot definedNFC, BLE and QR device engagement
Online presentationOpenID4VP 1.0 (dc+sd-jwt)OpenID4VP 1.0 (mso_mdoc), ISO 18013-7 Annex C, Digital Credentials API
Measured below: issued, then one of five claims shown1,112 bytes; 1,159 bytes1,046 bytes; 895 bytes (1,194 as base64url)

How selective disclosure works in each

In SD-JWT, the issuer turns each claim into a disclosure, the base64url encoding of [salt, name, value], and signs only the SHA-256 digests in the JWT's _sd array. The holder sends the JWT and the disclosures it chooses, separated by ~. The verifier hashes each disclosure and checks that the digest is in _sd; claims without a disclosure stay hidden, and the salt prevents guessing them from the digest. A Key Binding JWT (typ: kb+jwt) signed by the holder's key carries the verifier's aud and nonce and an sd_hash over the exact presentation.

In mdoc, each data element is an IssuerSignedItem (digest ID, random salt, identifier, value) encoded in CBOR and wrapped in tag 24. The Mobile Security Object lists the SHA-256 digests per namespace, the device public key and the validity period, and the issuer signs it with COSE. The holder returns only the selected items with the issuer-signed MSO, plus a device signature over a session transcript; for OpenID4VP that transcript contains a hash of the verifier's client ID, nonce and response URI.

The script below builds both formats with the same five attributes, signs them with P-256 keys and presents only age_over_18. It first checks the SD-JWT digest computation against the example in RFC 9901 §4.2.3 (Node.js 26, jose 6.2.13, cbor-x 1.6.0).

Build both formats and present one attribute
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)`);
Output
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)

The digest matches the value printed in RFC 9901. The sizes come from a minimal sketch without issuer certificates: real credentials add an x5c or x5chain certificate chain (often 1–2 KB), status information and more attributes, which affects both formats about equally. The pattern holds, though. An SD-JWT presentation is the full issuer JWT with all digests plus one disclosure and a key binding JWT, while an mdoc presentation drops the unselected items and keeps the MSO, so mdoc presentations shrink as more attributes stay hidden.

Where each one is used

The European Digital Identity regulation requires member states to provide wallets by December 2026, and private relying parties that must use strong user authentication, such as banks, to accept them by December 2027; the Architecture and Reference Framework requires providers to issue person identification data both as mdoc and as SD-JWT VC. Mobile driving licences follow ISO/IEC 18013-5 worldwide, and the US TSA and many US states accept them at checkpoints.

On the web, Chrome 141 and Safari 26 shipped the W3C Digital Credentials API in 2025, which lets a page request a credential from the platform wallet. Chrome accepts OpenID4VP requests, so both formats can be requested; Safari supports only the ISO 18013-7 Annex C protocol (org-iso-mdoc), so web verifiers that want iPhone users must accept mdoc. OpenID4VP 1.0, final since July 2025, defines the dc+sd-jwt and mso_mdoc formats and the DCQL query language for both.

Practical differences for implementers

  • Tooling: SD-JWT is JSON and JOSE, so any JWT library plus about a hundred lines of disclosure handling suffices; the SD-JWT inspector decodes them. mdoc needs CBOR, COSE and the ISO data model; the mdoc inspector and COSE decoder show the structures.
  • Data model: SD-JWT supports nested objects and individually disclosable array elements; mdoc uses flat namespaces of elements, with ISO-defined identifiers such as age_over_18.
  • Unlinkability: both reuse the same issuer signature and digests across presentations, so colluding verifiers can link them. Wallets mitigate this by issuing batches of credentials and using each once.
  • Trust: mdoc relies on issuer certificate chains up to country roots (IACA), which suits government issuers. SD-JWT VC supports web-based key discovery and X.509 alike.
  • Standards status: RFC 9901, ISO/IEC 18013-5 and OpenID4VP 1.0 are final. SD-JWT VC and the Token Status List are IETF drafts in the final stages (October 2026), so pin the draft version you implement.

When to choose which

  • Government identity, driving licences, age checks in person: mdoc.
  • Web verification that must work on iPhones today: mdoc through the Digital Credentials API.
  • EUDI Wallet person identification data: both, as the reference framework requires.
  • Diplomas, professional licences, employee or membership credentials issued online: SD-JWT VC.
  • Systems already built on OAuth and OpenID Connect: SD-JWT VC through OpenID4VCI and OpenID4VP.
  • Signed JSON claims without selective disclosure: a plain JWT or verifiable credential suffices.

Migration notes

  1. Implement OpenID4VP 1.0 with DCQL, which requests either format with the same query structure.
  2. Verify everything the standards require: issuer signature and trust chain, digests of every disclosed item, validity or status, and the holder binding against your own nonce and audience; skipping the binding turns a credential into a bearer token anyone can replay.
  3. Pin draft versions: record which SD-JWT VC and status list drafts you implement, and track changes until they become RFCs.
  4. Request the minimum: ask for age_over_18 rather than a birth date when that is the question; both formats support it.
  5. Plan certificate management: mdoc verifiers need country trust lists for IACA roots; SD-JWT VC verifiers need issuer metadata or certificate validation.

References

All comparisons