Engineering · 2026-04-06
Selective Disclosure with SD-JWT: Revealing Only the Claims That Matter
By J. W. Bouckaert
All-or-nothing credentials
A verifier that receives a plain JWT sees every claim in its payload. If a credential carries ten claims and the verifier needs one, the other nine are disclosed anyway.
The W3C Verifiable Credentials Data Model v2.0 describes selective disclosure but does not define one mechanism for it. The IETF defines one in SD-JWT (RFC 9901), published in November 2025, and profiles it for credentials in SD-JWT VC, an Internet-Draft whose latest revision is -19 (31 August 2026).
This article describes how SD-JWT works and how PasskeyBridge issues and verifies SD-JWT credentials, including where its implementation differs from the current drafts.
The SD-JWT mechanism
SD-JWT adds three things to a JWT:
- Disclosures. For an object property, a disclosure is the base64url encoding of a JSON array
[salt, claim_name, claim_value]. The salt is random, so the digest of a disclosure cannot be guessed from the claim value. - Digests. The issuer hashes each disclosure and puts the digests in an
_sdarray in place of the claims. The payload names the hash algorithm in_sd_alg. - Selective presentation. The holder receives the signed JWT and every disclosure. When presenting, the holder sends the JWT with only the disclosures it chooses. The verifier hashes each one and checks that the digest appears in
_sd.
The issuer commits to all claims at issuance through the digests. The holder decides at presentation which claims to reveal, without contacting the issuer.
A disclosure
A disclosure for the claim sim_swap_recent: false, with a 128-bit salt, is the array:
["ezLzRCbBT43J2SIo51_zNg","sim_swap_recent",false]
Its base64url encoding is the disclosure:
WyJlekx6UkNiQlQ0M0oyU0lvNTFfek5nIiwic2ltX3N3YXBfcmVjZW50IixmYWxzZV0
The base64url SHA-256 digest of that string, which goes in _sd, is:
U9NOoyR2hJMt1mUmpdBBpmYz-vcK4xWw8LvuVSIdkrY
The digest is computed over the encoded string, not over the JSON, so the verifier must hash the disclosure exactly as received.
Issuing an SD-JWT credential
A tenant issues credentials through the credential issuance API. The request names the subject, the credential type, the claims and, optionally, a disclosure_frame that marks which claims are selectively disclosable:
{
"subject_ref": "urn:pb:subject:v1:<64 lowercase hex>",
"credential_type": "CarrierVerification",
"claims": {
"line_type": "mobile",
"carrier_country": "GB",
"sim_swap_recent": false,
"checked_at": "2026-10-20T14:00:00Z",
"check_method": "carrier_lookup"
},
"disclosure_frame": {
"line_type": true,
"carrier_country": true,
"sim_swap_recent": true
},
"expiration_days": 90,
"holder_public_jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
The subject is either subject_did, the subject's DID, or subject_ref, a keyed digest of the DID that the integrator computes under its own key, so the DID never reaches PasskeyBridge. expiration_days defaults to 90 and is capped by the credential schema where one applies.
Claims marked true become disclosures. Claims that are absent from the frame or marked false stay visible in the payload. A nested frame applies selective disclosure inside an object claim. A structured frame on an array claim is refused. PasskeyBridge never makes the subject id selectively disclosable.
Supplying holder_public_jwk makes the credential holder-bound: the key goes in cnf.jwk, and the credential uses the SD-JWT envelope even if nothing is selectively disclosable, so a Key Binding JWT can travel with it.
Issuance steps
| Step | Operation |
|---|---|
| 1 | Apply the disclosure frame to the subject claims |
| 2 | Generate a 16-byte (128-bit) salt for each disclosable claim with crypto.getRandomValues() |
| 3 | Encode each disclosure as base64url of the JSON array |
| 4 | Hash each disclosure with SHA-256 and sort the digests into _sd |
| 5 | Sign the JWT with ES256 (ES384 on the ML-DSA-87 tier) and compute a detached ML-DSA proof over the same signing input |
The resulting payload, for the request above, has this shape (digests shortened here):
{
"iss": "did:web:...",
"sub": "urn:pb:subject:v1:...",
"jti": "urn:uuid:...",
"iat": 1792504800,
"nbf": 1792504800,
"exp": 1800280800,
"vc": {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"type": ["VerifiableCredential", "CarrierVerification"],
"credentialSubject": {
"id": "urn:pb:subject:v1:...",
"checked_at": "2026-10-20T14:00:00Z",
"check_method": "carrier_lookup",
"_sd": ["<digest 1>", "<digest 2>", "<digest 3>"]
}
},
"vct": "CarrierVerification",
"_sd_alg": "sha-256",
"cnf": { "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } }
}
The JWT header carries "alg": "ES256" (or ES384), "typ": "vc+sd-jwt" and the issuer key id. A verifier that receives this JWT with no disclosures sees when and how the check was made, and that three more claims exist, but not their values.
Differences from the current drafts
PasskeyBridge's SD-JWT module cites SD-JWT draft 13 and SD-JWT VC draft 05, and its output reflects them:
- The header
typisvc+sd-jwt. Current SD-JWT VC drafts usedc+sd-jwt. - The claims sit inside a W3C VC Data Model 1.1
vcobject, withvct,_sd_algandcnfat the payload root. SD-JWT VC puts the claims at the root.
A verifier built to the current SD-JWT VC draft may refuse these credentials until the format is updated.
The token
The issued string joins the issuer JWT and the disclosures with ~:
<issuer-jwt>~<disclosure 1>~<disclosure 2>~<disclosure 3>~
The trailing ~ means no Key Binding JWT is attached. The holder stores the whole string.
| Component | Contents | Who sees it |
|---|---|---|
| Issuer JWT | Visible claims and digests | Every verifier |
| Disclosures | Salted claim values | The holder, and each verifier the holder sends them to |
| Key Binding JWT | Holder signature over the presentation | The verifier it is addressed to |
Presenting and verifying
For a check that needs only the SIM swap result, the holder presents:
<issuer-jwt>~<sim_swap_recent disclosure>~<kb-jwt>
For each presented disclosure, PasskeyBridge's verifier decodes it, hashes the encoded string with SHA-256 and checks that the digest is in _sd. A disclosure that does not decode, or whose digest is not in the credential, is an error. A digest that is presented or referenced twice makes the credential malformed. The verified claims are returned with the list of digests that were not disclosed.
Key Binding JWT
Without holder binding, anyone who obtains a presentation can present it again. A Key Binding JWT (RFC 9901 section 4.3) ties a presentation to the holder's key and to one verifier. The holder's wallet:
- assembles the SD-JWT with the chosen disclosures, ending in
~; - computes
sd_hash, the SHA-256 of that string; - signs a JWT with header
typ: kb+jwtand the claimssd_hash,aud(the verifier),nonce(from the verifier) andiat; - appends it after the last
~.
PasskeyBridge's verifier, for a credential with cnf:
- refuses the credential if no Key Binding JWT is present, or if
cnfis anything other than an embedded public JWK; - checks the signature against
cnf.jwkwith ES256 or ES384, matching the key's curve; - checks that
iatis no more than 600 seconds old, with 300 seconds of clock skew allowed; - recomputes
sd_hashover the presented SD-JWT and compares it; - checks
audandnonceagainst the values the verifier supplies.
Because sd_hash covers the disclosures the holder chose, removing or adding a disclosure after the holder signs makes the hash differ, and the presentation is refused.
| Claim | Purpose |
|---|---|
sd_hash | Binds the signature to the exact set of presented disclosures |
aud | Limits the presentation to one verifier |
nonce | Ties the presentation to one request from that verifier |
iat | Limits how long a captured presentation stays usable |
Salt entropy
The salt is what keeps an undisclosed claim hidden. Without it, a verifier could hash each likely value of a claim, such as true and false, and compare the results with the digests. With a random salt, the verifier would have to guess the salt as well.
RFC 9901 section 9.3 recommends at least 128 bits of salt. PasskeyBridge uses 16 random bytes from crypto.getRandomValues(), which encode to 22 base64url characters.
| Salt size | Possible salts | Time to try every salt at 10^12 hashes per second |
|---|---|---|
| 64 bits | 1.8 × 10^19 | About 213 days |
| 128 bits | 3.4 × 10^38 | About 1.1 × 10^19 years |
Nested claims
A nested frame applies selective disclosure inside an object claim:
{
"claims": {
"carrier": { "country": "GB", "network_type": "mobile", "mcc_mnc": "23415" }
},
"disclosure_frame": {
"carrier": { "network_type": true, "mcc_mnc": true }
}
}
country stays visible inside carrier, and network_type and mcc_mnc each get their own disclosure, with their digests in an _sd array inside the carrier object.
Digest order
If digests appeared in the order the claims were written, a verifier who knows the schema could tell which digest belongs to which claim. RFC 9901 section 4.2.4.1 requires the issuer to hide the original order, for example by sorting the digests. PasskeyBridge sorts each _sd array:
if (sdDigests.length > 0) {
payload["_sd"] = sdDigests.sort();
}
PasskeyBridge does not add decoy digests, so the length of _sd shows how many claims are selectively disclosable.
Signatures
Selective disclosure works on the payload and does not depend on the signature algorithm. PasskeyBridge signs SD-JWT credentials the same way as its other credentials: a compact ES256 JWS that any JOSE library can verify against the issuer's DID-published key, plus a detached ML-DSA-65 proof (ML-DSA-87 on the higher tier, paired with ES384), described in hybrid signatures. The legacy PQC-HYBRID envelope is issued only to tenants that opt in to it.
| Layer | Technology |
|---|---|
| Payload | SD-JWT (RFC 9901) with SD-JWT VC draft 05 structure |
| Classical signature | ES256 (P-256), or ES384 (P-384) on the ML-DSA-87 tier |
| Post-quantum proof | ML-DSA-65 or ML-DSA-87 (FIPS 204), detached |
| Holder binding | Key Binding JWT, ES256 or ES384 |
Claim values and stored identifiers
The issuance API signs the claims the tenant submits and does not hash them. If a claim holds an unkeyed SHA-256 digest of a name, phone number or email address, the digest protects nothing: those inputs are low-entropy, so anyone holding the digest can recover the input by hashing candidates. An issuer that puts derived personal values in claims should key them, for example with HMAC-SHA-256 under a key the issuer holds.
PasskeyBridge stores less than the credential carries. Its issuance registry, event and audit rows hold only a keyed digest of the subject identifier, computed with HMAC-SHA-256 under a server-held pepper. With subject_ref, the subject DID never reaches the platform. Selective disclosure limits which claims a verifier sees; keyed digests limit what a stored identifier reveals. See zero-PII architecture for the storage side.
Batch presentations and status
PasskeyBridge's presentation endpoint accepts up to 20 tokens in one request, and the wallet SDK can assemble such a batch. Presentation requests use Presentation Exchange 2.0 definitions over OpenID4VP; OpenID4VP 1.0 replaced Presentation Exchange with the DCQL query language.
Credential status is checked by credential id against PasskeyBridge's status endpoint, which also publishes a W3C StatusList2021 bitstring. A status check reveals the state of one credential and nothing about which claims were disclosed in earlier presentations.
Read about hybrid signatures Read about zero-PII architecture Get started with PasskeyBridge