Selective Disclosure Mechanics¶
Level: Advanced cryptographic internals
Simple explanation¶
This document explains what happens inside SD-JWT: how salts prevent guessing, how disclosures become digests, how decoys hide the number of disclosable claims, and how verification reconstructs the original payload.
What you will learn¶
- How salt generation prevents preimage attacks
- How disclosures are encoded and hashed into digests
- How decoy digests protect privacy
- The verification algorithm
| Audience | Developers implementing custom SD-JWT issuance or verification, and security engineers auditing cryptographic choices. |
| Purpose | Detail the cryptographic primitives (salts, hashes, digests, decoys) that power selective disclosure so readers can reason about security properties and extend the library. |
| Scope | Salt generation, hash algorithm selection, disclosure encoding, digest computation, nested disclosure, decoy digests, and verification algorithm. Out of scope: high-level lifecycle (see SD-JWT). |
| Success criteria | Reader can trace a disclosure from creation through digest to verification, evaluate salt entropy, and explain why decoy digests prevent information leakage. |
For a conceptual introduction and basic usage, start with the SD-JWT.
Prerequisite: Read SD-JWT first. This page assumes you already understand issuance, presentation, and verification at the conceptual level. It goes deeper into the cryptographic internals.
Prerequisites¶
Before reading this document, you should be familiar with:
- Basic SD-JWT concepts from SD-JWT
- Cryptographic hash functions (SHA-256 family)
- Base64url encoding
- JSON serialization rules
Cryptographic foundations¶
Why salts matter¶
A disclosure without a salt would be vulnerable to preimage attacks. If an attacker knows the possible values of a claim (e.g., age is between 18-100), they could hash each possibility and compare against the _sd array to discover the hidden value.
Without salt (vulnerable):
Possible ages: 18, 19, 20, ..., 100
Attacker computes: HASH("age", 25), HASH("age", 26), ...
Attacker finds match in _sd array -> discovers age = 25
With salt (secure):
Salt is random 128-bit value: "_26bc4LT-ac6q2KI6cBAceg"
Disclosure: ["_26bc4LT-ac6q2KI6cBAceg", "age", 25]
Attacker cannot guess salt -> cannot precompute hash
Salt generation requirements¶
Per RFC 9901, salts must be:
- Cryptographically random (not predictable)
- At least 128 bits of entropy
- Unique per disclosure (never reused)
// SdJwt.Net salt generation
public static string GenerateSalt(int byteLength = 16) // 128 bits
{
var bytes = new byte[byteLength];
RandomNumberGenerator.Fill(bytes);
return Base64UrlEncoder.Encode(bytes);
}
Hash algorithm selection¶
RFC 9901 requires the hash algorithm to be specified in the _sd_alg claim. This library supports:
| Algorithm | _sd_alg Value |
Security Level | Recommendation |
|---|---|---|---|
| SHA-256 | sha-256 |
Standard | Default, recommended for most use cases |
| SHA-384 | sha-384 |
High | For higher security requirements |
| SHA-512 | sha-512 |
Very High | For maximum security |
| MD5 | N/A | Broken | BLOCKED - cryptographically broken |
| SHA-1 | N/A | Broken | BLOCKED - collision attacks proven |
Disclosure format specification¶
Object property disclosure¶
For object properties, the disclosure is a 3-element JSON array:
[salt, claim_name, claim_value]
Example:
["_26bc4LT-ac6q2KI6cBAceg", "email", "alice@example.com"]
Encoding process:
1. JSON serialize: '["_26bc4LT-ac6q2KI6cBAceg","email","alice@example.com"]'
2. UTF-8 encode to bytes
3. Base64url encode: 'WyJfMjZiYzRMVC1hYzZxMktJNmNCQWNlZyIsImVtYWlsIiwiYWxpY2VAZXhhbXBsZS5jb20iXQ'
Array element disclosure¶
For array elements, the disclosure is a 2-element JSON array (no claim name):
[salt, element_value]
Example (disclosing a nationality from a nationalities array):
["lklxF5jMYlGTPUovMNIvCA", "US"]
Implementation in SdJwt.Net (disclosure)¶
// From Models/Disclosure.cs
public Disclosure(string salt, string claimName, object claimValue)
{
Salt = salt;
ClaimName = claimName;
ClaimValue = claimValue;
object[] disclosureArray;
if (string.IsNullOrEmpty(ClaimName))
{
// Array element disclosure: [salt, value]
disclosureArray = new object[] { Salt, ClaimValue };
}
else
{
// Object property disclosure: [salt, name, value]
disclosureArray = new object[] { Salt, ClaimName, ClaimValue };
}
var json = JsonSerializer.Serialize(disclosureArray,
SdJwtConstants.DefaultJsonSerializerOptions);
EncodedValue = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(json));
}
Digest computation¶
Formula¶
digest = BASE64URL(HASH(ASCII(base64url_encoded_disclosure)))
Step-by-step example:
1. Disclosure array: ["_26bc4LT-ac6q2KI6cBAceg", "email", "alice@example.com"]
2. JSON serialize (UTF-8):
'["_26bc4LT-ac6q2KI6cBAceg","email","alice@example.com"]'
3. Base64url encode:
'WyJfMjZiYzRMVC1hYzZxMktJNmNCQWNlZyIsImVtYWlsIiwiYWxpY2VAZXhhbXBsZS5jb20iXQ'
4. Convert to ASCII bytes (the encoded string IS ASCII)
5. SHA-256 hash the bytes
6. Base64url encode the hash:
'JnPBS7TpL8ncxL-6mymWKgzZPk4J98xU8C4d1yXt9qE'
Implementation¶
// Digest computation in SdJwt.Net
public static string ComputeDigest(string encodedDisclosure, string algorithm = "sha-256")
{
using var hashAlgorithm = algorithm switch
{
"sha-256" => SHA256.Create(),
"sha-384" => SHA384.Create(),
"sha-512" => SHA512.Create(),
_ => throw new NotSupportedException($"Algorithm {algorithm} is not supported")
};
var bytes = Encoding.ASCII.GetBytes(encodedDisclosure);
var hash = hashAlgorithm.ComputeHash(bytes);
return Base64UrlEncoder.Encode(hash);
}
Nested selective disclosure¶
SD-JWT supports selective disclosure at any nesting level within JSON objects.
Example: nested address¶
Original claims:
{
"name": "Alice",
"address": {
"street": "123 Main St",
"city": "Springfield",
"country": "US"
}
}
With nested selective disclosure (city and country are disclosable):
{
"name": "Alice",
"address": {
"street": "123 Main St",
"_sd": ["digest_for_city", "digest_for_country"]
}
}
Separate disclosures:
["salt1", "city", "Springfield"]
["salt2", "country", "US"]
Implementation¶
var options = new SdIssuanceOptions
{
DisclosureStructure = new
{
address = new
{
city = true, // Selectively disclosable
country = true // Selectively disclosable
// street is NOT listed -> always visible
}
}
};
Decoy digests¶
Decoy digests prevent information leakage about the number of hidden claims. Without decoys, a verifier seeing 3 digests in _sd knows there are exactly 3 hidden claims, even if they cannot see the values.
Decoy digests are random hashes with no corresponding disclosure. They are cryptographically indistinguishable from real digests.
{
"patient": "John",
"_sd": [
"real_hash_1",
"decoy_hash_a",
"real_hash_2",
"decoy_hash_b",
"real_hash_3"
]
}
Now the verifier cannot determine how many real claims exist.
Decoy generation¶
public static string GenerateDecoyDigest()
{
var randomBytes = new byte[32]; // SHA-256 output size
RandomNumberGenerator.Fill(randomBytes);
return Base64UrlEncoder.Encode(randomBytes);
}
Verification algorithm¶
When a verifier receives a presentation with disclosures:
1. Parse the SD-JWT and extract the _sd array and _sd_alg
2. For each provided disclosure:
a. Decode the disclosure from Base64url
b. Validate the JSON structure (2 or 3 elements)
c. Compute digest using _sd_alg algorithm
d. Check if computed digest exists in _sd array
e. If not found, REJECT (disclosure was not issued)
3. For each digest in _sd array:
a. Either a matching disclosure was provided (claim revealed)
b. Or no disclosure provided (claim remains hidden)
c. Unmatched digests may represent either undisclosed real claims or decoy digests; the verifier cannot distinguish them
4. Extract revealed claims into the verified payload
Key Binding JWT (KB-JWT) hash¶
The KB-JWT contains an sd_hash claim that binds it to a specific SD-JWT presentation.
SD hash computation¶
sd_hash = BASE64URL(SHA-256(ASCII(sd_jwt_without_kb_jwt)))
This ensures the KB-JWT cannot be reused with a different SD-JWT presentation.
JSON serialization rules¶
Consistent JSON serialization is critical for digest matching.
Rules enforced by this library¶
- No whitespace between elements
- UTF-8 encoding for string values
- Consistent key ordering (as specified in the original claim)
- Standard JSON escaping for special characters
Why this matters¶
If the issuer and verifier use different JSON serialization:
Issuer: '["salt","email","alice@example.com"]' -> hash A
Verifier: '["salt", "email", "alice@example.com"]' -> hash B (space added)
hash A != hash B -> Verification fails!
Security properties at a glance¶
| Property | Mechanism | What it prevents |
|---|---|---|
| Claim integrity | Issuer signature over digests | Tampering with claim values |
| Claim privacy | Hashed digests in _sd |
Verifier seeing unrevealed claims |
| Salt entropy | Minimum 128-bit random salt per disclosure | Preimage (guessing) attacks |
| Count privacy | Decoy digests | Leaking how many claims exist |
| Holder binding | KB-JWT with cnf key proof |
Stolen SD-JWT reuse by another party |
| Presentation freshness | Nonce and aud in KB-JWT |
Replay and forwarding attacks |
| Serialization determinism | Strict JSON encoding rules | Cross-implementation digest mismatches |
Implementation pitfalls¶
| Pitfall | Impact | Mitigation |
|---|---|---|
| Weak salt entropy | Attacker can brute-force claim values | Use cryptographically random bytes, minimum 128 bits |
| Missing decoy digests | Verifier infers claim count from _sd length |
Add decoy digests; the library supports this automatically |
| Inconsistent JSON serialization | Digest mismatch between issuer and verifier | Use the library's serializer; do not hand-build disclosure JSON. The digest is computed over the exact base64url-encoded disclosure string, so any whitespace or key-order difference produces a different hash |
| Duplicate disclosures | Same claim processed twice or ambiguous payload reconstruction | Reject duplicate disclosures or duplicate claim reconstruction paths |
| Accepting disclosures without matching digest | Attacker injects claims the issuer never signed | Always verify each disclosure hash exists in _sd before trusting |
Skipping sd_hash validation in KB-JWT |
KB-JWT can be reattached to a different presentation | Validate sd_hash matches the presented SD-JWT string |
Implementation references¶
| Component | File | Description |
|---|---|---|
| Disclosure model | Disclosure.cs | Disclosure creation and parsing |
| Hash utilities | SdJwtUtils.cs | Salt generation and digest computation |
| Parser | SdJwtParser.cs | SD-JWT string parsing |
| Constants | SdJwtConstants.cs | Algorithm names and claim constants |
Related concepts¶
- SD-JWT - Conceptual introduction and basic usage
- Verifiable Credential - Using SD-JWT for credentials
- HAIP Profile Validation Guide - Algorithm requirements for high assurance