Engineering · 2026-04-06
Selective Disclosure with SD-JWT: Revealing Only the Claims That Matter
By J. W. Bouckaert
The problem with all-or-nothing credentials
Traditional JWTs are binary. When you present a JWT to a verifier, every claim in the payload is visible. Every one. If your identity credential contains your name, date of birth, address, national ID number, employer, and credit score—the airport lounge verifier who only needs to confirm you're over 18 sees all of it.
This is a fundamental architectural failure that the identity industry has tolerated for over a decade.
The credential that proves you're over 18 should not also reveal your home address. Selective disclosure is a prerequisite for ethical credential design rather than a feature.
The W3C Verifiable Credentials Data Model v2.0 acknowledges this requirement but does not prescribe an implementation. The IETF filled that gap with SD-JWT (RFC 9901), published as a Standards Track RFC in November 2025, and its verifiable credential profile SD-JWT-VC (draft-ietf-oauth-sd-jwt-vc-16). PasskeyBridge implements both, natively, with hybrid PQC signatures, and with zero PII stored at any layer.
This article walks through the complete SD-JWT-VC lifecycle as implemented in our credential issuance pipeline: from disclosure frame to signed token, from holder wallet to verifier presentation, and from digest verification to claim-stripping attack prevention.
The SD-JWT mechanism
SD-JWT extends the JWT format with three concepts:
- Disclosures: A disclosure is a base64url-encoded JSON array containing three elements:
[salt, claim_name, claim_value]. The salt is a cryptographically random 128-bit value that prevents brute-force enumeration of hidden claims.
- Digests: Each disclosure is hashed with SHA-256 to produce a digest. These digests are collected in an
_sdarray within the JWT payload, replacing the original claim key-value pairs.
- Selective Presentation: The holder receives both the signed JWT and all disclosures. When presenting to a verifier, the holder includes only the disclosures for claims they want to reveal. The verifier hashes each presented disclosure and checks that the resulting digest appears in the
_sdarray.
The elegance of this design is that the issuer commits to all claims at issuance time (via the digests), but the holder controls which claims are revealed at presentation time—without requiring re-issuance or issuer involvement.
The disclosure data structure
A single disclosure for a claim date_of_birth: "1990-03-15" looks like this:
["WyJHaFkzMk5OdGt4ZkRDajgz...", "date_of_birth", "1990-03-15"]
Encoded as base64url:
WyJHaFkzMk5OdGt4ZkRDajgzIiwgImRhdGVfb2ZfYmlydGgiLCAiMTk5MC0wMy0xNSJd
The SHA-256 digest of this encoded string becomes one entry in the _sd array. The original date_of_birth key is removed entirely from the JWT payload—it exists only in the disclosure.
PasskeyBridge's SD-JWT-VC issuance pipeline
When a tenant issues a credential through the shield-vc-issue endpoint, the request body includes an optional disclosure_frame—a declarative object that specifies which claims should be selectively disclosable:
{
"subject_did": "did:key:z6Mkhafc...",
"credential_type": "IdentityAttestation",
"claims": {
"given_name_hash": "a1b2c3...",
"family_name_hash": "d4e5f6...",
"date_of_birth_hash": "789abc...",
"nationality_hash": "def012...",
"document_type": "passport",
"issuing_authority_hash": "345678...",
"address_hash": "9abcde...",
"phone_hash": "f01234...",
"email_hash": "567890...",
"employer_hash": "abcdef...",
"credit_tier": "A",
"verification_method": "in_person"
},
"disclosure_frame": {
"given_name_hash": true,
"family_name_hash": true,
"date_of_birth_hash": true,
"nationality_hash": true,
"address_hash": true,
"phone_hash": true,
"email_hash": true,
"employer_hash": true,
"credit_tier": true,
"issuing_authority_hash": true
},
"expiration_days": 90,
"holder_public_jwk": {
"kty": "EC",
"crv": "P-256",
"x": "f83Og...",
"y": "x_FEz..."
}
}
In this example, 12 claims are issued. The disclosure frame marks 10 of them as selectively disclosable. Two claims—document_type and verification_method—are always visible because they are absent from the frame. The credentialSubject.id (the subject DID) is automatically protected from selective disclosure per the W3C VC specification.
Pipeline operations
The issuance pipeline executes five operations in sequence:
| Step | Operation | Output |
|---|---|---|
| 1 | Parse disclosure frame | Map of claim → SD/visible |
| 2 | Generate 128-bit salt per SD claim | 10 unique salts |
| 3 | Create disclosures: base64url([salt, key, value]) | 10 encoded disclosures |
| 4 | Hash each disclosure with SHA-256 | 10 digests in _sd array |
| 5 | Sign JWT with hybrid PQC (ES256 + ML-DSA-65) | SD-JWT-VC token |
The resulting JWT payload contains only the always-visible claims and the _sd array of digests:
{
"iss": "did:web:passkeybridge.io:tenants:acme",
"sub": "did:key:z6Mkhafc...",
"jti": "urn:uuid:550e8400-e29b-41d4-a716-446655440000",
"iat": 1712419200,
"exp": 1720195200,
"_sd_alg": "sha-256",
"cnf": {
"jwk": { "kty": "EC", "crv": "P-256", "x": "f83Og...", "y": "x_FEz..." }
},
"vc": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential", "IdentityAttestation"],
"credentialSubject": {
"id": "did:key:z6Mkhafc...",
"document_type": "passport",
"verification_method": "in_person",
"_sd": [
"2h0e...", "3Kp1...", "7xRm...", "Bq4n...",
"Fj8k...", "Lm2p...", "Qw9r...", "Tv5s...",
"Xz1u...", "Yc6w..."
]
}
}
}
Notice: the 10 selectively disclosable claims are gone from the payload. Only their digests remain in the sorted _sd array. A verifier who receives this JWT without any disclosures can see that the subject has a passport verified in person—but nothing else.
The final token format
The complete SD-JWT-VC string follows the format specified in the IETF draft:
<issuer-jwt>~<disclosure1>~<disclosure2>~...~<disclosureN>~
Each ~ delimiter separates a component. The trailing ~ indicates no Key Binding JWT is appended (more on KB-JWT below). The issuer delivers this complete string—JWT plus all disclosures—to the holder's wallet. The holder stores everything.
| Component | Purpose | Who Sees It |
|---|---|---|
| Issuer JWT | Signed credential with digests | Everyone |
| Disclosures | Encoded claim values | Holder only (until presented) |
| KB-JWT (optional) | Proves holder possession | Verifier |
Selective presentation: The holder's choice
When the holder presents the credential to a verifier—say, an age-verification service—they include only the disclosures for the claims the verifier needs. For an age check, the holder presents:
<issuer-jwt>~<date_of_birth_hash_disclosure>~
One disclosure out of ten. The verifier receives the JWT, parses the single disclosure, computes its SHA-256 digest, and confirms that the digest exists in the _sd array. The other nine claims remain hidden—the verifier cannot derive them from the digests because each disclosure contains a 128-bit random salt.
The verification algorithm
PasskeyBridge's verifyDisclosures() function executes the following steps for each presented disclosure:
- Decode: Parse the base64url-encoded disclosure into
[salt, claim_name, claim_value] - Hash: Compute
SHA-256(encoded_disclosure)to produce the digest - Match: Verify the digest exists in the JWT's
_sdarray - Deduplicate: Reject any disclosure whose digest has already been matched (replay protection)
| Check | Failure Mode | Response |
|---|---|---|
| Decode | Malformed base64url or JSON | Reject disclosure |
| Hash match | Digest not in _sd array | Reject—possible tampering |
| Duplicate | Same digest presented twice | Reject—replay attempt |
If all checks pass, the verified claims are reconstructed and returned to the relying party. The unmatched digests in _sd are reported as undisclosed—the verifier knows they exist but cannot see their contents.
Claim-stripping attacks and KB-JWT
There is a subtle but critical attack vector in naive SD-JWT implementations: claim-stripping. A malicious intermediary intercepts the holder's presentation and removes one or more disclosures before forwarding it to the verifier. The verifier sees fewer disclosed claims and might approve a transaction that the holder intended to restrict.
Consider: a holder presents a credential to a lending platform with disclosures for credit_tier and employer_hash. A man-in-the-middle strips the employer_hash disclosure. The lender sees only the credit tier and—depending on their policy—may approve the loan without employment verification. The holder's intent (to prove both claims) is subverted.
The KB-JWT defense
PasskeyBridge addresses this with Key Binding JWT (KB-JWT), specified in RFC 9901 §4.3. When the holder's wallet creates a presentation, it:
- Assembles the SD-JWT with only the selected disclosures:
<issuer-jwt>~<d1>~<d2>~...~ - Computes the SHA-256 hash of this entire string—the
sd_hash - Signs a KB-JWT containing
sd_hash, the verifier's audience (aud), a replay nonce, and the issuance timestamp - Appends the KB-JWT to the SD-JWT:
<issuer-jwt>~<d1>~<d2>~...~<kb-jwt>
The KB-JWT is signed with the holder's ECDSA P-256 private key—the corresponding public key is embedded in the credential's cnf (confirmation) claim at issuance time. The verifier validates the KB-JWT signature against the cnf.jwk and checks that the sd_hash matches the SHA-256 of the SD-JWT it received.
If any disclosure is stripped after the holder signs, the sd_hash will not match. The verifier rejects the presentation.
| KB-JWT Claim | Purpose | Attack It Prevents |
|---|---|---|
sd_hash | SHA-256 of the SD-JWT string | Claim-stripping |
aud | Verifier identifier (DID or URL) | Token replay to wrong verifier |
nonce | Single-use challenge from verifier | Replay attacks |
iat | Issuance timestamp | Stale presentations |
Salt entropy and brute-force resistance
The security of selective disclosure depends entirely on the unpredictability of the disclosure salt. If an attacker can guess the salt, they can compute the digest for a suspected claim value and check it against the _sd array—effectively de-anonymizing the hidden claim.
PasskeyBridge generates salts using crypto.getRandomValues(), which draws from the operating system's CSPRNG (Cryptographically Secure Pseudo-Random Number Generator). Each salt is 16 bytes (128 bits), base64url-encoded.
The brute-force resistance is straightforward:
| Salt Size | Search Space | Time at 10^12 hashes/sec |
|---|---|---|
| 64 bits | 1.8 × 10^19 | ~5 hours |
| 128 bits | 3.4 × 10^38 | ~10^19 years |
| 256 bits | 1.2 × 10^77 | Heat death of universe |
128 bits is the recommended entropy in RFC 9901 §4.2.1 and §9.3. It is computationally infeasible to enumerate even for nation-state adversaries with access to purpose-built hardware. We do not use 256-bit salts because they increase disclosure size without meaningful security benefit at current and projected computational capabilities.
Nested selective disclosure
PasskeyBridge supports structured selective disclosure—where a claim value is itself an object, and individual sub-claims within it can be independently disclosed or hidden. This is critical for address credentials where a verifier might need the country but not the street:
{
"claims": {
"address": {
"street_hash": "abc...",
"city_hash": "def...",
"country": "US",
"postal_code_hash": "ghi..."
}
},
"disclosure_frame": {
"address": {
"street_hash": true,
"city_hash": true,
"postal_code_hash": true
}
}
}
The applyDisclosureFrame() function recurses into nested objects: country remains visible in the JWT payload while street_hash, city_hash, and postal_code_hash each get their own disclosure and digest. The holder can present country (always visible) plus city_hash (selectively) without revealing the street or postal code.
Digest ordering and information leakage
A subtle implementation detail: the order of digests in the _sd array must not leak information about the original claim ordering. If digests were stored in insertion order, a verifier who knows the schema could infer which position corresponds to which claim—even without the disclosure.
PasskeyBridge sorts the _sd array lexicographically before embedding it in the JWT payload. RFC 9901 §4.2.4.1 requires the Issuer to hide the original claim order and recommends sorting alphanumerically (or randomly) after optionally adding decoy digests. This is implemented in the applyDisclosureFrame() function:
if (sdDigests.length > 0) {
// Per RFC 9901 §4.2.4.1: hide original claim order to prevent leakage
payload["_sd"] = sdDigests.sort();
}
Sorting destroys the positional correlation between digests and claims. Without the disclosure (and its 128-bit salt), the verifier cannot determine which digest corresponds to which claim name.
Integration with hybrid PQC signatures
SD-JWT-VC is signature-algorithm-agnostic. The selective disclosure mechanism operates on the JWT payload structure—it does not care how the JWT is signed. This is a critical architectural property because it means selective disclosure composes cleanly with PasskeyBridge's hybrid PQC signing scheme.
Every SD-JWT-VC issued by PasskeyBridge carries:
- An ES256 (ECDSA P-256) classical signature for immediate backward compatibility
- An ML-DSA-65 (FIPS 204) lattice signature for quantum resilience
The JWT header declares "alg": "PQC-HYBRID" and "typ": "vc+sd-jwt". The signature segment contains a JSON object with both signatures, the algorithm identifier, the key fingerprint, and the quantum nonce.
| Layer | Technology | Purpose |
|---|---|---|
| Payload | SD-JWT-VC (draft-16) over SD-JWT (RFC 9901) | Selective disclosure |
| Signature (classical) | ES256 (P-256) | Backward-compatible verification |
| Signature (PQC) | ML-DSA-65 (FIPS 204) | Quantum-resilient verification |
| Holder binding | ECDSA P-256 KB-JWT | Claim-stripping prevention |
This layered architecture means a credential issued today with selective disclosure will remain verifiable and quantum-resilient when cryptographically relevant quantum computers arrive. The holder's selective disclosure choices are protected by both classical and post-quantum cryptography.
The zero-PII guarantee
Even the "visible" claims in PasskeyBridge credentials are hashed values. The claim given_name_hash is a SHA-256 digest of the actual given name—not the name itself. Selective disclosure operates on these hashes.
This creates a two-layer privacy architecture:
- Layer 1 (SD-JWT): The holder chooses which claim hashes to disclose
- Layer 2 (Zero-PII): Even disclosed claims contain no plaintext PII—only cryptographic commitments
A verifier who receives a disclosed phone_hash can confirm it matches a phone number they independently collected (by hashing it and comparing), but they cannot derive the phone number from the hash. Selective disclosure on top of zero-PII architecture is defense in depth for credential privacy.
Practical implications for credential design
The availability of selective disclosure changes how credentials should be designed. Instead of issuing narrow, single-purpose credentials (one for age, one for employment, one for address), issuers can create rich, multi-claim credentials where the holder controls what is revealed per transaction.
| Approach | Credentials Needed | Issuer Interactions | Holder Control |
|---|---|---|---|
| Single-purpose JWTs | 6 separate credentials | 6 issuance ceremonies | None (all-or-nothing) |
| SD-JWT-VC (12 claims) | 1 credential | 1 issuance ceremony | Per-claim, per-verifier |
This reduces issuance overhead, simplifies wallet management, and—most importantly—puts the holder in control of their data at presentation time rather than issuance time.
Batch presentations and status binding
SD-JWT-VC as implemented today handles single-credential selective presentation. The PBWallet SDK already supports batch VP token presentation (up to 20 tokens per request) and OID4VCI redemption, enabling holders to present multiple SD-JWT-VCs in a single OpenID4VP exchange.
The next phase integrates StatusList2021 revocation with selective disclosure—ensuring that a revoked credential's status can be checked without revealing which claims were disclosed in prior presentations. This is the intersection of privacy and auditability that enterprise deployments demand.
Selective disclosure is the minimum viable architecture for credential privacy in modern identity systems, where no verifier needs, or should receive, every claim.