SD-JWT Credential Format for eIDAS 2.0 EUDIW: Complete Selective Disclosure Implementation Guide 2026
Post #3 in the sota.io EU Digital Identity & eIDAS 2.0 Developer Series
Post #1 covered the EUDIW architecture and Relying Party integration. Post #2 walked through the OpenID4VP cross-device flow. This post focuses on the actual credential payload: SD-JWT VC, the format the EU Digital Identity Wallet uses to represent and share identity attributes.
Understanding SD-JWT is non-negotiable if you're building a Relying Party. When you receive a VP Token from a wallet, the verifiable_credential inside it is an SD-JWT. You need to parse it, verify the issuer signature, reconstruct the disclosed claims, and check the holder binding — all before trusting any identity attribute.
This guide walks through the full format, the cryptographic mechanics, and complete TypeScript implementations for all three roles: issuer, holder, and verifier.
What Is SD-JWT?
SD-JWT (Selective Disclosure JWT) is an IETF specification (draft-ietf-oauth-selective-disclosure-jwt) that extends JSON Web Tokens to enable selective disclosure: the holder of a credential can reveal only a chosen subset of attributes to a verifier, without exposing the rest — and without the issuer's involvement at presentation time.
The classic JWT problem: if an issuer puts { "name": "Alice", "age": 34, "address": "Berlin", "salary": 85000 } into a signed JWT, Alice must hand over the entire blob to any verifier. She cannot prove her name without also revealing her salary.
SD-JWT solves this with a hash-based commitment scheme:
- The issuer replaces sensitive claims with hash commitments in the JWT body
- The raw claim values are packed into separate disclosure objects (short, base64url-encoded blobs)
- The holder receives the full set of disclosures but only presents the subset needed for each transaction
- The verifier receives the JWT body plus the selected disclosures, verifies the issuer signature over the body, then reconstructs only the disclosed claims
The issuer's signature covers only the hashes — never the raw values. This gives the holder genuine selective disclosure without requiring per-request issuer participation.
SD-JWT Structure Anatomy
A complete SD-JWT presentation looks like this:
<Issuer-signed JWT>~<Disclosure 1>~<Disclosure 2>~...<Disclosure N>~[KB-JWT]
The ~ character (tilde) is the separator. The trailing [KB-JWT] is an optional Key Binding JWT used to bind the presentation to the holder's key and prevent replay attacks. EUDIW requires key binding — you will always see a KB-JWT at the end.
Issuer-Signed JWT
The JWT body (decoded) looks like:
{
"iss": "https://pid.eudi.de/issuer",
"iat": 1748649600,
"exp": 1780185600,
"vct": "https://credentials.eudi.eu/PersonalIdentityData/1.0",
"cnf": {
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
}
},
"_sd_alg": "sha-256",
"_sd": [
"hTMo6TFtCM5zXHmh1uXQNWAhcLFSwKb3wPQVVAI7xVA",
"Z3Bpb25lZVBldGVyc29u2K3t-E0C1VrCEWRbkF9tMHQ",
"VtFSmfBCPMwq4HxHiKrg-PZlxuJn5pFN3Vq7VvpxaXc",
"9XLBnUCZ_gFlT3JMeWsB4J4mJG6QJb9lR5bKpZoIqH4",
"Jq2K-yPJHk6HkDo0gQZ8vPaMHn6HBX1NLmhTlVcUFn0"
],
"status": {
"status_list": {
"idx": 4723,
"uri": "https://pid.eudi.de/statuslist/1"
}
}
}
Key fields:
vct: Verifiable Credential Type — identifies the credential schema (PID, mDL, EAA)cnf.jwk: Holder's public key (used to verify the Key Binding JWT)_sd_alg: Hash algorithm used for disclosures — alwayssha-256in EUDIW_sd: Array of SHA-256 hashes of the disclosure objects
Claims that are not in _sd are disclosed to all verifiers unconditionally. Claims that are in _sd require the matching disclosure object to be revealed.
Disclosure Objects
Each disclosure is a compact representation of a single claim. The raw structure before encoding:
["salt_value", "claim_name", claim_value]
Where:
salt_valueis a cryptographically random 16-byte value (base64url-encoded), unique per claim per issuanceclaim_nameis the attribute key (e.g.,"family_name","birth_date")claim_valueis the raw value
Example for the given_name claim:
// Raw disclosure array
const disclosure = [
"eluV5Og3gSNII8EYnsxA_A", // random salt
"given_name",
"Alice"
]
// Encode to base64url
const encoded = base64url(JSON.stringify(disclosure))
// → "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwiZ2l2ZW5fbmFtZSIsIkFsaWNlIl0"
// SHA-256 hash → this goes in the JWT's _sd array
const hash = sha256(encoded)
// → "hTMo6TFtCM5zXHmh1uXQNWAhcLFSwKb3wPQVVAI7xVA"
The issuer puts the hash in the JWT body; the raw disclosure travels separately. The verifier reconstructs sha256(disclosure) == hash_in_jwt to confirm authenticity.
Key Binding JWT (KB-JWT)
The KB-JWT proves that the current SD-JWT presentation was created by the legitimate holder — not replayed from a previously stolen presentation. It is a regular JWT signed with the holder's private key (corresponding to the cnf.jwk in the issuer JWT).
{
"typ": "kb+jwt",
"alg": "ES256"
}
{
"iat": 1748735400,
"aud": "https://rp.example.eu",
"nonce": "n-0S6_WzA2Mj",
"sd_hash": "X9bFHfZ7MXVLtVKPNkrMH8MuKHF-pTEV2noBzF1YXMU"
}
The sd_hash field is the SHA-256 hash of <Issuer-JWT>~<Disclosure1>~...~<DisclosureN>~ (everything up to but not including the KB-JWT itself). This binds the KB-JWT to the specific set of disclosures being presented.
The nonce is provided by your Relying Party in the Authorization Request — it ensures the KB-JWT is fresh and targeted at your specific request.
EUDIW Credential Types
The EU Digital Identity Wallet supports multiple credential types defined in the Architecture Reference Framework (ARF v2.4.0). All use SD-JWT VC format.
Personal Identity Data (PID)
The PID is the core identity credential, issued by national identity providers (e.g., Bundesdruckerei in Germany, DXC in the Netherlands).
{
"vct": "https://credentials.eudi.eu/PersonalIdentityData/1.0",
"claims": {
"family_name": "Müller",
"given_name": "Klaus",
"birth_date": "1985-03-12",
"birth_place": "Hamburg",
"resident_address": {
"street_address": "Hauptstraße 23",
"locality": "Hamburg",
"postal_code": "20095",
"country": "DE"
},
"nationality": ["DE"],
"personal_identifier": "DE/XX/12345678-A",
"issuing_country": "DE",
"issuing_authority": "Bundesdruckerei GmbH",
"document_number": "T220001293",
"issuance_date": "2024-06-01",
"expiry_date": "2034-05-31",
"portrait": "<base64-encoded JPEG>",
"age_over_18": true,
"age_over_21": true,
"age_in_years": 41,
"age_birth_year": 1985
}
}
All claims except issuing_country and personal_identifier are selectively disclosable. Typical Relying Party use cases:
- Age verification → request only
age_over_18 - KYC → request
given_name,family_name,birth_date,nationality - Address verification → request
resident_address
Mobile Driving Licence (mDL)
Based on ISO 18013-5 but wrapped in SD-JWT for the EUDIW ecosystem.
{
"vct": "https://credentials.eudi.eu/mDL/1.0",
"claims": {
"document_number": "DL-DE-123456",
"driving_privileges": [
{
"vehicle_category_code": "B",
"issue_date": "2010-07-15",
"expiry_date": "2030-07-15"
}
],
"un_distinguishing_sign": "D",
"issue_date": "2020-07-15",
"expiry_date": "2030-07-15",
"sex": 1
}
}
Electronic Attribute Attestations (EAA)
EAAs are domain-specific credentials issued by qualified trust service providers. Examples: professional licences (lawyer, doctor, architect), student IDs, loyalty cards, employee credentials.
{
"vct": "https://credentials.attorney-bar.de/ProfessionalLicence/1.0",
"claims": {
"profession": "Rechtsanwalt",
"registration_number": "RAK-BE-45678",
"registration_body": "Rechtsanwaltskammer Berlin",
"valid_from": "2008-01-15",
"valid_until": "2027-12-31",
"jurisdictions": ["DE"]
}
}
TypeScript Implementation: Issuer Side
Here's a complete TypeScript issuer that creates SD-JWT PID credentials:
import { createHash, randomBytes } from 'crypto'
import { SignJWT, importJWK, JWK } from 'jose'
interface DisclosureMap {
[claimName: string]: {
salt: string
value: unknown
hash: string
encoded: string
}
}
function base64url(input: string | Buffer): string {
const buf = typeof input === 'string' ? Buffer.from(input) : input
return buf.toString('base64url')
}
function sha256(input: string): string {
return createHash('sha256').update(input).digest('base64url')
}
function createDisclosure(claimName: string, value: unknown): {
hash: string
encoded: string
salt: string
} {
const salt = base64url(randomBytes(16))
const disclosureArray = [salt, claimName, value]
const encoded = base64url(JSON.stringify(disclosureArray))
const hash = sha256(encoded)
return { hash, encoded, salt }
}
interface PIDClaims {
family_name: string
given_name: string
birth_date: string
birth_place?: string
resident_address?: object
nationality?: string[]
personal_identifier: string
issuing_country: string
issuing_authority: string
document_number: string
issuance_date: string
expiry_date: string
age_over_18?: boolean
age_in_years?: number
}
async function issuePIDCredential(
claims: PIDClaims,
holderPublicKey: JWK,
issuerPrivateKey: JWK,
issuerKeyId: string
): Promise<string> {
// Always-disclosed claims (not selective)
const alwaysDisclosed = ['issuing_country', 'personal_identifier']
// Build selective disclosures for all other claims
const selectiveClaimNames = Object.keys(claims).filter(
(k) => !alwaysDisclosed.includes(k)
)
const disclosures: DisclosureMap = {}
const sdHashes: string[] = []
for (const claimName of selectiveClaimNames) {
const value = (claims as Record<string, unknown>)[claimName]
if (value === undefined) continue
const d = createDisclosure(claimName, value)
disclosures[claimName] = { ...d, value }
sdHashes.push(d.hash)
}
// JWT body: only always-disclosed claims + _sd array
const jwtPayload = {
iss: 'https://pid.eudi.de/issuer',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60, // 1 year
vct: 'https://credentials.eudi.eu/PersonalIdentityData/1.0',
cnf: { jwk: holderPublicKey },
_sd_alg: 'sha-256',
_sd: sdHashes,
issuing_country: claims.issuing_country,
personal_identifier: claims.personal_identifier,
status: {
status_list: {
idx: Math.floor(Math.random() * 100000),
uri: 'https://pid.eudi.de/statuslist/1',
},
},
}
const issuerKey = await importJWK(issuerPrivateKey, 'ES256')
const issuerJWT = await new SignJWT(jwtPayload)
.setProtectedHeader({
alg: 'ES256',
typ: 'vc+sd-jwt',
kid: issuerKeyId,
})
.sign(issuerKey)
// Assemble: <JWT>~<disc1>~<disc2>~...
const disclosureEncodings = Object.values(disclosures).map((d) => d.encoded)
const sdJwt = [issuerJWT, ...disclosureEncodings, ''].join('~')
return sdJwt
}
TypeScript Implementation: Holder Side (Selective Disclosure)
The holder receives the full SD-JWT from the issuer, then creates a presentation by selecting which disclosures to include:
import { SignJWT, importJWK, decodeJwt, JWK } from 'jose'
interface ParsedSDJWT {
issuerJWT: string
disclosures: Map<string, string> // claimName → encoded disclosure
kbJwt: string | null
}
function parseSDJWT(sdJwt: string): ParsedSDJWT {
const parts = sdJwt.split('~')
const issuerJWT = parts[0]
// Last element is either empty string or KB-JWT
const hasKbJwt = parts[parts.length - 1] !== ''
const kbJwt = hasKbJwt ? parts[parts.length - 1] : null
const rawDisclosures = hasKbJwt ? parts.slice(1, -1) : parts.slice(1, -1)
const disclosures = new Map<string, string>()
for (const encoded of rawDisclosures) {
if (!encoded) continue
const decoded = JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf8')
)
// decoded = [salt, claimName, value]
const claimName = decoded[1] as string
disclosures.set(claimName, encoded)
}
return { issuerJWT, disclosures, kbJwt }
}
async function createPresentation(
sdJwt: string,
claimsToDisclose: string[],
holderPrivateKey: JWK,
rpAudience: string,
rpNonce: string
): Promise<string> {
const { issuerJWT, disclosures } = parseSDJWT(sdJwt)
// Select only the requested disclosures
const selectedDisclosures: string[] = []
for (const claimName of claimsToDisclose) {
const encoded = disclosures.get(claimName)
if (encoded) {
selectedDisclosures.push(encoded)
}
}
// Build the presentation body (before KB-JWT)
const presentationBody = [issuerJWT, ...selectedDisclosures, ''].join('~')
// Compute sd_hash over presentation body
const sdHash = createHash('sha256')
.update(presentationBody)
.digest('base64url')
// Create Key Binding JWT
const holderKey = await importJWK(holderPrivateKey, 'ES256')
const kbJwt = await new SignJWT({
sd_hash: sdHash,
aud: rpAudience,
nonce: rpNonce,
iat: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ alg: 'ES256', typ: 'kb+jwt' })
.sign(holderKey)
// Final presentation: body + KB-JWT
return presentationBody + kbJwt
}
// Example: holder discloses only name + age for a service that needs age verification
const presentation = await createPresentation(
storedSDJWT,
['given_name', 'family_name', 'age_over_18'],
holderPrivateKey,
'https://rp.example.eu',
rpNonce
)
TypeScript Implementation: Verifier Side
The verifier receives the presentation and must perform a multi-step verification:
import { jwtVerify, createRemoteJWKSet, importJWK } from 'jose'
import { createHash } from 'crypto'
interface VerifiedCredential {
issuer: string
credentialType: string
holderPublicKey: JWK
claims: Record<string, unknown>
issuedAt: Date
expiresAt: Date
isExpired: boolean
isRevoked: boolean
}
async function verifySDJWTPresentation(
presentation: string,
expectedAudience: string,
expectedNonce: string,
issuerTrustList: Map<string, URL> // issuer → JWKS endpoint
): Promise<VerifiedCredential> {
// 1. Split presentation into components
const parts = presentation.split('~')
const issuerJWT = parts[0]
const kbJwt = parts[parts.length - 1]
if (!kbJwt) {
throw new Error('Missing Key Binding JWT — holder binding required')
}
const rawDisclosures = parts.slice(1, -1).filter(Boolean)
// 2. Decode issuer JWT header to get issuer and key ID
const issuerHeader = JSON.parse(
Buffer.from(issuerJWT.split('.')[0], 'base64url').toString()
)
const issuerPayloadRaw = JSON.parse(
Buffer.from(issuerJWT.split('.')[1], 'base64url').toString()
)
const issuer: string = issuerPayloadRaw.iss
// 3. Verify issuer JWT signature using EUDIW trust framework
const jwksUrl = issuerTrustList.get(issuer)
if (!jwksUrl) {
throw new Error(`Issuer ${issuer} not in trust list`)
}
const JWKS = createRemoteJWKSet(jwksUrl)
const { payload: issuerPayload } = await jwtVerify(issuerJWT, JWKS, {
issuer,
clockTolerance: 60,
})
// 4. Verify Key Binding JWT
const holderJWK = (issuerPayload.cnf as { jwk: JWK }).jwk
const holderPublicKey = await importJWK(holderJWK, 'ES256')
const { payload: kbPayload } = await jwtVerify(kbJwt, holderPublicKey, {
audience: expectedAudience,
clockTolerance: 60,
})
// 5. Verify nonce (prevents replay)
if (kbPayload.nonce !== expectedNonce) {
throw new Error('Nonce mismatch — possible replay attack')
}
// 6. Verify sd_hash binds KB-JWT to this exact presentation
const presentationBody = parts.slice(0, -1).join('~') + '~'
const expectedSdHash = createHash('sha256')
.update(presentationBody)
.digest('base64url')
if (kbPayload.sd_hash !== expectedSdHash) {
throw new Error('sd_hash mismatch — presentation tampering detected')
}
// 7. Reconstruct disclosed claims from disclosures
const sdHashes = (issuerPayload._sd as string[]) || []
const claims: Record<string, unknown> = {}
// Add always-disclosed claims
const reservedKeys = ['iss', 'iat', 'exp', 'vct', 'cnf', '_sd_alg', '_sd', 'status']
for (const [key, value] of Object.entries(issuerPayload)) {
if (!reservedKeys.includes(key)) {
claims[key] = value
}
}
// Add selectively disclosed claims
for (const encoded of rawDisclosures) {
const decoded = JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf8')
)
const [salt, claimName, value] = decoded
// Verify this disclosure was committed to by the issuer
const hash = createHash('sha256').update(encoded).digest('base64url')
if (!sdHashes.includes(hash)) {
throw new Error(
`Disclosure for ${claimName} not found in issuer's _sd array`
)
}
claims[claimName] = value
}
// 8. Check credential expiry
const exp = issuerPayload.exp as number
const isExpired = exp < Math.floor(Date.now() / 1000)
// 9. Check revocation (status list)
const statusInfo = issuerPayload.status as {
status_list: { idx: number; uri: string }
}
const isRevoked = await checkStatusList(
statusInfo.status_list.uri,
statusInfo.status_list.idx
)
return {
issuer: issuerPayload.iss as string,
credentialType: issuerPayload.vct as string,
holderPublicKey: holderJWK,
claims,
issuedAt: new Date((issuerPayload.iat as number) * 1000),
expiresAt: new Date(exp * 1000),
isExpired,
isRevoked,
}
}
async function checkStatusList(
statusListUri: string,
idx: number
): Promise<boolean> {
// RFC 9449 Token Status List implementation
const response = await fetch(statusListUri, {
headers: { Accept: 'application/statuslist+jwt' },
})
if (!response.ok) return false
const statusJwt = await response.text()
// Decode and check the bit at position idx
const payload = JSON.parse(
Buffer.from(statusJwt.split('.')[1], 'base64url').toString()
)
const statusListBytes = Buffer.from(payload.status_list.lst, 'base64url')
const byteIdx = Math.floor(idx / 8)
const bitIdx = idx % 8
return ((statusListBytes[byteIdx] >> bitIdx) & 1) === 1
}
Verification Checklist for EUDIW Relying Parties
When receiving a VP Token containing an SD-JWT PID, your verifier MUST check all of the following before trusting any claims:
interface EUDIWVerificationResult {
// Signature checks
issuerSignatureValid: boolean // Step 1: JWT signature valid with trusted issuer key
issuerInTrustFramework: boolean // Step 2: Issuer is a registered QTSP/PID Provider
// Holder binding checks
holderKeyBindingValid: boolean // Step 3: KB-JWT signed with key matching cnf.jwk
nonceMatches: boolean // Step 4: nonce in KB-JWT matches our request
sdHashMatches: boolean // Step 5: sd_hash in KB-JWT matches presentation body
// Credential validity
notExpired: boolean // Step 6: exp is in the future
notRevoked: boolean // Step 7: status list check
disclosuresVerified: boolean // Step 8: all disclosures have valid hashes in _sd
// Policy checks
meetsRequestedClaims: boolean // Step 9: all requested claims were disclosed
vctMatchesExpected: boolean // Step 10: vct matches expected credential type
}
Fail the verification if any check fails. Do not accept partial results.
Nested Claims and Array Disclosures
SD-JWT supports nested selective disclosure for objects and arrays. For the resident_address object in a PID, the issuer can make individual address fields selectively disclosable:
{
"resident_address": {
"_sd": [
"street_hash_here",
"postal_code_hash_here",
"locality_hash_here"
],
"country": "DE"
}
}
This lets the wallet reveal country without disclosing street_address — useful for country-of-residence checks.
For arrays (e.g., driving_privileges), each element can be wrapped in an object with an _sd key:
{
"driving_privileges": [
{
"...": "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwi..."
}
]
}
The "..." key is the SD-JWT reserved key for array element disclosures. This allows hiding individual array elements while preserving the array structure.
Common Implementation Pitfalls
1. Forgetting to verify KB-JWT before trusting claims
The most critical mistake. Without KB-JWT verification, a stolen SD-JWT from one RP session can be replayed to your service. Always verify nonce, aud, sd_hash, and the holder key binding.
// ❌ WRONG: trusting claims before holder binding check
const claims = extractClaims(sdJwt)
// ✅ CORRECT: verify KB-JWT first
const result = await verifySDJWTPresentation(sdJwt, myAudience, myNonce, trustList)
if (!result.holderKeyBindingValid) throw new Error('Holder binding failed')
2. Accepting disclosures not committed to by the issuer
A malicious holder could inject fake disclosures. Always check that every presented disclosure has a matching hash in the issuer JWT's _sd array.
// ❌ WRONG: using decoded disclosures without checking _sd
const decoded = base64urlDecode(disclosure)
// ✅ CORRECT: verify hash is in issuer's _sd
const hash = sha256(disclosure)
if (!issuerPayload._sd.includes(hash)) throw new Error('Unverified disclosure')
3. Confusing SD-JWT with JWT
SD-JWT has a different typ header: vc+sd-jwt for credentials, kb+jwt for Key Binding JWTs. Some JOSE libraries reject JWTs with unknown typ values by default.
// Configure jose to accept SD-JWT types
const { payload } = await jwtVerify(token, key, {
typ: 'vc+sd-jwt' // explicitly allow this type
})
4. Clock skew without tolerance
EUDIW wallets and servers across EU member states may have clock differences up to 60 seconds. Always set clockTolerance: 60 in your JWT verification.
5. Not checking vct against your expected credential type
A verifier that requests a PID should reject an mDL being presented in its place. Always verify vct matches the credential type you requested in your Presentation Definition.
Testing Your Implementation
Use the EUDIW reference implementation for testing:
// eudi-lib-jvm-sd-jwt-kt (Kotlin) or eudi-lib-js-sd-jwt (TypeScript)
// Reference test vectors are available in the IETF SD-JWT spec appendix
// Test vector: known SD-JWT with known disclosures
const TEST_SD_JWT = `eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK3NkLWp3dCJ9...`
const TEST_DISCLOSURES = [
"WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwiZ2l2ZW5fbmFtZSIsIkFsaWNlIl0",
"WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwiZmFtaWx5X25hbWUiLCJNw7xsbGVyIl0"
]
// Verify against known-good values before testing your own credentials
const result = await verifySDJWTPresentation(
TEST_SD_JWT + TEST_DISCLOSURES.join('~') + '~' + TEST_KB_JWT,
'https://test.rp.example',
'test-nonce-123',
testTrustList
)
assert(result.claims.given_name === 'Alice')
assert(result.claims.family_name === 'Müller')
What's Next in This Series
This post covered the SD-JWT credential format in depth. The remaining posts in the EU-EIDAS-2-DEVELOPER-2026 series:
- Post #4: EUDIW Trust Framework — QTSP Certificates, Certificate Validation, and Root of Trust for Relying Parties
- Post #5: End-to-End EUDIW Integration: Putting ARF, OpenID4VP, and SD-JWT Together in a Production SaaS
If you are building a EUDIW integration and need a sovereign European cloud infrastructure to run it on, sota.io gives you EU-only compute with GDPR compliance by default — no transatlantic data transfers, no Cloud Act exposure.
EU-Native Hosting
Ready to move to EU-sovereign infrastructure?
sota.io is a German-hosted PaaS — no CLOUD Act exposure, no US jurisdiction, full GDPR compliance by design. Deploy your first app in minutes.