Back

How selective disclosure works in SD-JWT

The issuer signs digests, not values, so a holder can release some claims and withhold others. What the verifier sees, and the two details most write-ups miss.

The idea

A signed credential normally forces a choice: present all of it or none of it. Every claim is inside one signature, so removing a claim breaks the signature. That is why so much identity infrastructure ends up over-sharing, and why a verifier that only needed your age ends up holding your date of birth.

SD-JWT gets out of that by signing digests rather than values. The issuer hashes each selectively disclosable claim and signs the list of hashes. The values themselves travel separately, and the holder decides which ones to include. Drop one and nothing breaks, because the signature was never over the value.

What the issuer signs

The payload carries an _sd array holding one digest per selectively disclosable claim, and an _sd_alg naming the hash. If _sd_alg is absent the default is sha-256, which every implementation has to support. Anything the issuer wants always visible, such as the credential type in vct, sits in the payload as an ordinary claim.

The digests below are written as placeholders. Real ones are base64url-encoded hashes, and nothing in a post is worth trying to verify by hand.

{
  "iss": "https://soverage.com",
  "vct": "https://soverage.com/vct/personhood",
  "_sd_alg": "sha-256",
  "_sd": [
    "<digest of personhood_score>",
    "<digest of attestation_count>",
    "<decoy>"
  ]
}

What a disclosure is

One base64url-encoded JSON array per claim, holding a salt, the claim name and the claim value. For an array element it holds just the salt and the element. The salt has to be unique per claim, and that is not decoration: without it, a verifier holding a digest of a low-entropy value such as a score out of a hundred could simply hash every candidate until one matched.

The verifier recomputes the digest of each disclosure it receives and looks for it in _sd. A match means the issuer signed that exact name and value. A disclosure that is absent leaves a digest in the list that the verifier cannot resolve, which is exactly what withholding a claim looks like from the outside.

disclosure (decoded)
  ["<salt>", "personhood_score", 82]

digest
  base64url( sha-256( base64url-encoded disclosure ) )

What the holder sends

The issuer-signed JWT and the chosen disclosures, joined by tildes. Add a Key Binding JWT at the end and it becomes an SD-JWT+KB. Two presentations of the same credential differ only in how many segments are between the tildes.

everything:   <issuer-signed JWT>~<score>~<count>~<kb-jwt>
score only:   <issuer-signed JWT>~<score>~<kb-jwt>

same signature in both. nothing was re-issued.

The two details that are easy to miss

Decoy digests. The _sd array is visible, so its length tells a verifier how many selectively disclosable claims exist even for the ones it never receives. An issuer can add digests of random numbers that correspond to no claim at all, which makes the count meaningless. If your credential has two claims and you care that a verifier cannot tell it has two, you want decoys.

And sd_hash. The Key Binding JWT carries iat, aud, nonce, and sd_hash, a hash over the issuer-signed JWT together with the disclosures actually being presented. That last one is what stops disclosures being mixed between presentations: without it a holder signature would cover the transaction but not the particular set of claims released in it, and a captured presentation could be re-assembled with a different selection. Check it alongside the nonce and the audience, which is the fifth item in the verifier list.

None of this says anything about who is allowed to issue the credential. That question is separate, it is answered by a trusted list in the wallet ecosystem and by DID resolution in an open one, and it is worth keeping in its own layer.

Get your personhood credential

Verify through the Gateway and receive a DID, a verifiable credential and a personhood token. No personal data is retained.

Stay updated.