PasskeyBridge —

Engineering · 2026-04-06

Selective Disclosure with SD-JWT: Revealing Only the Claims That Matter

By J. W. Bouckaert

Selective Disclosure with SD-JWT: Revealing Only the Claims That Matter

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:

  1. 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.
  2. Digests. The issuer hashes each disclosure and puts the digests in an _sd array in place of the claims. The payload names the hash algorithm in _sd_alg.
  3. 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

StepOperation
1Apply the disclosure frame to the subject claims
2Generate a 16-byte (128-bit) salt for each disclosable claim with crypto.getRandomValues()
3Encode each disclosure as base64url of the JSON array
4Hash each disclosure with SHA-256 and sort the digests into _sd
5Sign 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 typ is vc+sd-jwt. Current SD-JWT VC drafts use dc+sd-jwt.
  • The claims sit inside a W3C VC Data Model 1.1 vc object, with vct, _sd_alg and cnf at 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.

ComponentContentsWho sees it
Issuer JWTVisible claims and digestsEvery verifier
DisclosuresSalted claim valuesThe holder, and each verifier the holder sends them to
Key Binding JWTHolder signature over the presentationThe 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:

  1. assembles the SD-JWT with the chosen disclosures, ending in ~;
  2. computes sd_hash, the SHA-256 of that string;
  3. signs a JWT with header typ: kb+jwt and the claims sd_hash, aud (the verifier), nonce (from the verifier) and iat;
  4. 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 cnf is anything other than an embedded public JWK;
  • checks the signature against cnf.jwk with ES256 or ES384, matching the key's curve;
  • checks that iat is no more than 600 seconds old, with 300 seconds of clock skew allowed;
  • recomputes sd_hash over the presented SD-JWT and compares it;
  • checks aud and nonce against 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.

ClaimPurpose
sd_hashBinds the signature to the exact set of presented disclosures
audLimits the presentation to one verifier
nonceTies the presentation to one request from that verifier
iatLimits 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 sizePossible saltsTime to try every salt at 10^12 hashes per second
64 bits1.8 × 10^19About 213 days
128 bits3.4 × 10^38About 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.

LayerTechnology
PayloadSD-JWT (RFC 9901) with SD-JWT VC draft 05 structure
Classical signatureES256 (P-256), or ES384 (P-384) on the ML-DSA-87 tier
Post-quantum proofML-DSA-65 or ML-DSA-87 (FIPS 204), detached
Holder bindingKey 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

Start free · Test the API