SD-JWT VC と mdoc のどちらを使うかは、デジタル ID ウォレット、モバイル運転免許証、EU デジタル ID ウォレットの根底にある形式の問題です。SD-JWT(RFC 9901)は、クレームをソルト付きダイジェストに置き換えた JWT で、対応する値は個別のディスクロージャーとして一緒に送られます。SD-JWT VC は、これにクレデンシャルの型と発行者の鍵ディスカバリを加えたものです。mdoc(ISO/IEC 18013-5)は CBOR のドキュメントで、そのデータ要素のダイジェストが、COSE で署名されたモバイルセキュリティオブジェクト(MSO)にまとめられます。どちらも保有者が選んだ属性だけを開示し、提示者がデバイス鍵を保有していることを証明します。
比較表
| SD-JWT VC | mdoc(ISO/IEC 18013-5) | |
|---|---|---|
| 中核となる仕様 | RFC 9901 SD-JWT(2025年11月)、SD-JWT VC(IETF ドラフト、IESG に提出済み) | ISO/IEC 18013-5:2021、オンラインでの提示は ISO/IEC TS 18013-7:2025 |
| エンコーディング | JSON、base64url、~ で区切ったディスクロージャー | CBOR(RFC 8949) |
| 発行者の署名 | JWS(通常は ES256) | モバイルセキュリティオブジェクトに対する COSE_Sign1 |
| 選択的開示 | _sd に入るクレームごとのソルト付きダイジェスト。ネストしたオブジェクトと配列要素にも対応 | 名前空間ごと、データ要素ごとのソルト付きダイジェスト。フラットな要素リスト |
| 保有者バインディング | cnf 内の鍵。aud、nonce、sd_hash を含む Key Binding JWT | MSO 内のデバイス鍵。セッショントランスクリプトに対するデバイス署名または MAC |
| クレデンシャルの型 | 型メタデータを伴う vct URI | docType(例:org.iso.18013.5.1.mDL) |
| 発行者の鍵ディスカバリ | .well-known/jwt-vc-issuer、x5c、または DID | IACA ルートまでの x5chain(国のトラストリスト) |
| 失効 | Token Status List(RFC Editor のキューにある IETF ドラフト) | 短い有効期間、新しいプロファイルではステータスリスト |
| 対面での提示 | 規定なし | NFC、BLE、QR によるデバイスエンゲージメント |
| オンラインでの提示 | OpenID4VP 1.0(dc+sd-jwt) | OpenID4VP 1.0(mso_mdoc)、ISO 18013-7 附属書 C、Digital Credentials API |
| 以下の実測値:発行時、5 つのクレームのうち 1 つを提示した時 | 1,112 バイト、1,159 バイト | 1,046 バイト、895 バイト(base64url では 1,194) |
それぞれの選択的開示の仕組み
SD-JWT では、発行者が各クレームを、[salt, name, value] を base64url エンコードしたディスクロージャーに変換し、JWT の _sd 配列に入れた SHA-256 ダイジェストだけに署名します。保有者は、JWT と自分が選んだディスクロージャーを ~ で区切って送ります。検証者は各ディスクロージャーのハッシュを計算し、そのダイジェストが _sd に含まれていることを確認します。ディスクロージャーのないクレームは隠されたままで、ソルトがあるためダイジェストから値を推測することもできません。保有者の鍵で署名された Key Binding JWT(typ: kb+jwt)には、検証者の aud と nonce、および提示内容そのものに対する sd_hash が含まれます。
mdoc では、各データ要素は IssuerSignedItem(ダイジェスト ID、ランダムなソルト、識別子、値)として CBOR でエンコードされ、タグ 24 で包まれます。モバイルセキュリティオブジェクトには、名前空間ごとの SHA-256 ダイジェスト、デバイスの公開鍵、有効期間が記載され、発行者がこれに COSE で署名します。保有者は、選択した項目だけを発行者が署名した MSO とともに返し、さらにセッショントランスクリプトに対するデバイス署名を添えます。OpenID4VP の場合、このトランスクリプトには検証者のクライアント ID、nonce、レスポンス URI のハッシュが含まれます。
次のスクリプトは、同じ 5 つの属性で両方の形式を構築し、P-256 の鍵で署名して、age_over_18 だけを提示します。最初に、SD-JWT のダイジェスト計算を 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)
ダイジェストは RFC 9901 に記載された値と一致します。サイズは、発行者の証明書を含まない最小限のスケッチで計測したものです。実際のクレデンシャルには x5c または x5chain の証明書チェーン(多くの場合 1〜2 KB)、ステータス情報、さらに多くの属性が加わりますが、その影響は両方の形式でほぼ同じです。それでも傾向は変わりません。SD-JWT の提示は、すべてのダイジェストを含む発行者の JWT 全体に、1 つのディスクロージャーと Key Binding JWT を加えたものです。一方、mdoc の提示は選択されなかった項目を省いて MSO を残すため、隠したままの属性が多いほど mdoc の提示は小さくなります。
それぞれの利用状況
欧州デジタル ID 規則は、加盟国に対して 2026年12月までにウォレットを提供することを、また銀行など強力なユーザー認証を義務付けられている民間のリライングパーティに対して 2027年12月までにウォレットを受け入れることを求めています。さらに『アーキテクチャおよびリファレンスフレームワーク』は、個人識別データの提供者に対し、そのデータを mdoc と SD-JWT VC の両方で発行することを求めています。モバイル運転免許証は世界的に ISO/IEC 18013-5 に準拠しており、米国では TSA(運輸保安庁)と多くの州が検査場でこれを受け入れています。
Web では、2025年に Chrome 141 と Safari 26 が W3C Digital Credentials API を搭載しました。この API を使うと、ページがプラットフォームのウォレットにクレデンシャルを要求できます。Chrome は OpenID4VP のリクエストを受け付けるため、両方の形式を要求できます。一方、Safari が対応しているのは ISO 18013-7 附属書 C のプロトコル(org-iso-mdoc)だけなので、iPhone ユーザーに対応したい Web の検証者は mdoc を受け入れなければなりません。2025年7月に最終版となった OpenID4VP 1.0 は、dc+sd-jwt と mso_mdoc の形式、および両方に使える DCQL クエリ言語を定義しています。
実装者から見た実際の違い
- ツール:SD-JWT は JSON と JOSE なので、任意の JWT ライブラリに 100 行ほどのディスクロージャー処理を加えれば足ります。SD-JWT インスペクターでデコードできます。mdoc には CBOR、COSE、ISO のデータモデルが必要です。mdoc インスペクターと COSE デコーダーで構造を確認できます。
- データモデル:SD-JWT は、ネストしたオブジェクトと、個別に開示できる配列要素に対応しています。mdoc は要素をフラットに並べた名前空間を使い、
age_over_18のような ISO が定めた識別子を用います。 - 連結不能性:どちらも提示のたびに同じ発行者の署名とダイジェストを再利用するため、結託した検証者は提示同士を結び付けることができます。ウォレットは、クレデンシャルをまとめて発行してもらい、それぞれを 1 回だけ使うことでこれを緩和します。
- 信頼:mdoc は国のルート(IACA)に至る発行者の証明書チェーンに依存しており、政府の発行者に向いています。SD-JWT VC は、Web ベースの鍵ディスカバリと X.509 の両方に対応しています。
- 標準化の状況:RFC 9901、ISO/IEC 18013-5、OpenID4VP 1.0 は最終版です。SD-JWT VC と Token Status List は最終段階にある IETF ドラフト(2026年10月時点)なので、実装するドラフトのバージョンを固定してください。
それぞれの用途
- 政府発行の ID、運転免許証、対面での年齢確認:mdoc を使います。
- 現時点で iPhone でも動作しなければならない Web での検証:Digital Credentials API を通じて mdoc を使います。
- EUDI ウォレットの個人識別データ:リファレンスフレームワークの要件どおり、両方に対応します。
- オンラインで発行する卒業証書、職業資格、従業員や会員のクレデンシャル:SD-JWT VC を使います。
- OAuth と OpenID Connect の上にすでに構築されたシステム:OpenID4VCI と OpenID4VP を通じて SD-JWT VC を使います。
- 選択的開示を必要としない署名付きの JSON クレーム:通常の JWT か検証可能なクレデンシャルで足ります。
移行時の注意点
- DCQL を使って OpenID4VP 1.0 を実装する:DCQL なら、どちらの形式も同じクエリ構造で要求できます。
- 標準が求めるすべてを検証する:発行者の署名と信頼チェーン、開示されたすべての項目のダイジェスト、有効期間またはステータス、そして自分の nonce とオーディエンスに照らした保有者バインディングです。バインディングの検証を省くと、クレデンシャルは誰でもリプレイできるベアラートークンになってしまいます。
- ドラフトのバージョンを固定する:実装する SD-JWT VC とステータスリストのドラフトを記録し、RFC になるまで変更を追跡します。
- 必要最小限だけを要求する:確認したいのが 18 歳以上かどうかだけなら、生年月日ではなく
age_over_18を要求します。どちらの形式もこれに対応しています。 - 証明書の管理を計画する:mdoc の検証者には IACA ルートのための国のトラストリストが、SD-JWT VC の検証者には発行者のメタデータまたは証明書の検証が必要です。