Skip to content

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

  1. No whitespace between elements
  2. UTF-8 encoding for string values
  3. Consistent key ordering (as specified in the original claim)
  4. 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