Sitemap

SD-JWT Programming

11 min readNov 27, 2025

--

Introduction

Selective Disclosure using JWTs — known as SD-JWT — has finally been published by the IETF as an official standard: RFC 9901 Selective Disclosure for JSON Web Tokens. Following RFC 9449 (DPoP), I’m grateful that my company, Authlete, is once again listed among the specification authors.

Press enter or click to view image in full size

Because SD-JWT also serves as the foundation for SD-JWT VC, one of the formats for Verifiable Credentials, it is also gaining attention in the context of digital identity.

There are already numerous articles that cover the purpose and format of SD-JWT, so I won’t repeat those explanations here. Instead, this article assumes you are already familiar with the SD-JWT specification and focuses on how to actually read and write SD-JWTs in code.

Programming will be done using Authlete’s open-source Java SD-JWT library (authlete/sd-jwt). This library is used not only in Authlete’s implementation of the OpenID for Verifiable Credential Issuance 1.0 specification, but also in the official conformance suite provided by the OpenID Foundation.

Library Setup

The SD-JWT library is published in the Maven Central Repository under the name com.authlete:sd-jwt. You can use it by adding the following dependency to your pom.xml file:

<dependency>
<groupId>com.authlete</groupId>
<artifactId>sd-jwt</artifactId>
<version>${sd-jwt.version}</version>
</dependency>

Replace ${sd-jwt.version} with the actual version number you intend to use.

NOTE: The SD-JWT library depends on GSON.

Disclosure

The following diagram illustrates the structure of an SD-JWT.

Press enter or click to view image in full size

Disclosures are an essential component of SD-JWT. In this SD-JWT library, each Disclosure is represented by the Disclosure class.

A Disclosure consists of three elements — salt, claim name, and claim value — when it corresponds to a property of an object. In contrast, when it corresponds to an array element, it consists of only two elements: salt and claim value. The difference is simply whether a claim name is included.

To create a Disclosure representing an object property, use one of the following constructors, both of which accept the claim name as an argument:

Disclosure(String claimName, Object claimValue)
Disclosure(String salt, String claimName, Object claimValue)

When you use the constructor with two arguments, the salt is automatically generated inside the constructor.

// Three arguments passed to a Disclosure constructor
String salt = "_26bc4LT-ac6q2KI6cBW5es";
String claimName = "family_name";
Object claimValue = "Möbius";

// Create a Disclosure instance
Disclosure disclosure = new Disclosure(salt, claimName, claimValue);
// Two arguments passed to a Disclosure constructor
String claimName = "family_name";
Object claimValue = "Möbius";

// Create a Disclosure instance with a salt generated automatically
Disclosure disclosure = new Disclosure(claimName, claimValue);

To create a Disclosure representing an array element, either pass null for the claim name argument or simply use the constructor that accepts only a single argument.

// A claim value (an array element)
Object claimValue = "array-element0";

// Create a Disclosure instance
Disclosure disclosure = new Disclosure(claimValue);

Once you have created a Disclosure instance, you can obtain its serialized form using either the getDisclosure() method or the toString() method.

// Serialize the Disclosure
// serialized1 and serialized2 will have the same value
String serialized1 = disclosure.getDisclosure();
String serialized2 = disclosure.toString();

You can create a Disclosure instance from its serialized form using the parse(String) method.

// Parse a serialized Disclosure
Disclosure disclosure = Disclosure.parse(
"WyJfMjZiYzRMVC1hYzZxMktJNmNCVzVlcyIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0");

Disclosure Digest

In the payload of the SD-JWT Issuer-signed JWT, it is not the Disclosure itself but the digest of the Disclosure that is included. You can obtain the digest using one of the following methods:

digest()
digest(String hashAlgorithm)

The digest() method without arguments calculates the digest using the default hash algorithm, SHA-256. On the other hand, the digest(String) method takes a String argument specifying the hash algorithm to use for computing the digest.

// Obtain the digest of a Disclosure using the default hash algorithm (SHA-256)
String digestSha256 = disclosure.digest();

// Obtain the digest using the SHA-512 hash algorithm
String digestSha512 = disclosure.digest("sha-512");

Selective Disclosure Object

The SDObjectBuilder class is a utility for creating a Map instance that represents a JSON object containing the _sd array.

A typical usage pattern for SDObjectBuilder is as follows:

  1. Create an instance of SDObjectBuilder.
  2. Add regular claims as needed.
  3. Add digest values of Disclosures as needed.
  4. Call the build() method to generate the Map instance.
// Create an SDObjectBuilder instance
SDObjectBuilder builder = new SDObjectBuilder();

// Add a regular claim
builder.putClaim("my_claim_name", "my_claim_value");

// Add the digest of a Disclosure
Disclosure disclosure = new Disclosure(
"_26bc4LT-ac6q2KI6cBW5es", "family_name", "Möbius");
builder.putSDClaim(disclosure);

// Generate the Map instance
Map<String, Object> map = builder.build();

The Map instance obtained from the code above contains the same content as the following JSON:

{
"my_claim_name": "my_claim_value",
"_sd": [
"TZjouOTrBKEwUNjNDs9yeMzBoQn8FFLPaJjRRmAtwrM"
]
}

If you want to specify a hash algorithm other than the default (SHA-256) for computing Disclosure digests in SDObjectBuilder, use the constructor that accepts the algorithm name as an argument.

// SDObjectBuilder constructor that takes a hash algorithm as an argument
SDObjectBuilder builder = new SDObjectBuilder("sha-512");

When generating the Map instance, if you call build(true) instead of build(), the name of the hash algorithm will be included in the Map as the value of the _sd_alg claim.

// Embed the hash algorithm in the Map
Map<String, Object> map = builder.build(true);

The following JSON illustrates the data contained in a Map instance generated using build(true):

{
"_sd": [
"j35wlGQlyQ8b4OE3Py6l3AAvOskjcNOxj0SsiVSrVdmVs8bapSUelViRDbmlntFABkp6_zSz1fA-dlWGUxGpEA"
],
"_sd_alg": "sha-512"
}

Decoy Digest

To prevent an attacker from easily guessing the original number of claims, you can include meaningless Disclosure digest values that do not correspond to any actual claims. These digest values are called Decoy Digests (RFC 9901 Section 4.2.5, Decoy Digests).

Get Takahiko Kawasaki’s stories in your inbox

Join Medium for free to get updates from this writer.

You can add Decoy Digests using the putDecoyDigest() or putDecoyDigests(int) methods.

// Add a single Decoy Digest
builder.putDecoyDigest();

// Add the specified number of Decoy Digests
builder.putDecoyDigests(3);

Issuer-signed JWT

When an SD-JWT string is split by ~, the first element is a JWT. In RFC 9901, this JWT is called the Issuer-signed JWT. Note that in the authlete/sd-jwt library, which was implemented when the specification was still in flux, this JWT is referred to as the Credential JWT.

The authlete/sd-jwt library provides the SDObjectBuilder class as a utility for generating the payload of this JWT. However, the library does not provide utilities for creating the JWT itself.

This is an intentional design choice, allowing developers to use any JWT library they prefer. Personally, I recommend the Nimbus JOSE + JWT library, but the authlete/sd-jwt library itself does not depend on Nimbus JOSE + JWT.

Below is an example program that creates an Issuer-signed JWT. The SDObjectBuilder class from authlete/sd-jwt is used to generate the payload, while Nimbus JOSE + JWT classes are used for all other processing.

import java.text.*;
import java.util.*;
import com.authlete.sd.*;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.*;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.gen.*;
import com.nimbusds.jwt.*;

public class SDJWTExample
{
public static void main(String[] args) throws Exception
{
// Prepare selectively-disclosable claims as a list of Disclosures
List<Disclosure> disclosures = buildDisclosures();

// Prepare regular claims
Map<String, Object> regularClaims = buildRegularClaims();

// Generate a private key to sign the Issuer-signed JWT
ECKey privateKey = generatePrivateKey();

// Create the Issuer-signed JWT
String issuerSignedJwt =
buildIssuerSignedJwt(disclosures, regularClaims, privateKey);

// Output the Issuer-signed JWT
System.out.println(issuerSignedJwt);
}

private static List<Disclosure> buildDisclosures()
{
// List of Disclosures
List<Disclosure> disclosures = new ArrayList<>();

// Add a Disclosure representing the claim "family_name":"Kawasaki"
disclosures.add(new Disclosure("family_name", "Kawasaki"));

// Add a Disclosure representing the claim "given_name":"Takahiko"
disclosures.add(new Disclosure("given_name", "Takahiko"));

return disclosures;
}

private static Map<String, Object> buildRegularClaims()
{
// Map of "claim name -> claim value"
Map<String, Object> claims = new LinkedHashMap<>();

// Add the claim "nickname":"Taka"
claims.put("nickname", "Taka");

// Add the "vct" claim according to the SD-JWT VC specification
claims.put("vct", "https://credentials.example.com/identity_credential");

return claims;
}

private static ECKey generatePrivateKey() throws JOSEException
{
// Generate a private key for the ES256 algorithm
return new ECKeyGenerator(Curve.P_256).generate();
}

private static String buildIssuerSignedJwt(
List<Disclosure> disclosures, Map<String, Object> regularClaims, ECKey privateKey)
throws JOSEException, ParseException
{
// Create the payload part of the Issuer-signed JWT
Map<String, Object> claims = buildPayload(disclosures, regularClaims);

// Create the unsigned Issuer-signed JWT
SignedJWT jwt = buildJwt(claims);

// Sign the Issuer-signed JWT
signJwt(jwt, privateKey);

// Serialize the Issuer-signed JWT
String serialized = jwt.serialize();

return serialized;
}

private static Map<String, Object> buildPayload(
List<Disclosure> disclosures, Map<String, Object> regularClaims)
{
// Create an SDObjectBuilder instance to construct the payload
// of the Issuer-signed JWT. Here we use the default constructor,
// so the hash algorithm will be the default (SHA-256).
SDObjectBuilder builder = new SDObjectBuilder();

// Add selectively-disclosable claims
disclosures.forEach(builder::putSDClaim);

// Add regular claims
regularClaims.forEach((k, v) -> builder.putClaim(k, v));

// Build the Map instance representing the payload.
// This Map will include the "_sd" array.
Map<String, Object> payload = builder.build();

return payload;
}

private static SignedJWT buildJwt(Map<String, Object> payload)
throws JOSEException, ParseException
{
// Create the header part of the Issuer-signed JWT
//
// The header will be {"alg":"ES256","typ":"dc+sd-jwt"}.
// The "typ" value is defined in the SD-JWT VC specification.
JWSHeader header =
new JWSHeader.Builder(JWSAlgorithm.ES256)
.type(new JOSEObjectType("dc+sd-jwt"))
.build();

// Convert the Map payload to a JWTClaimsSet using
// the Nimbus JOSE + JWT library
JWTClaimsSet claimsSet = JWTClaimsSet.parse(payload);

// Create the unsigned Issuer-signed JWT
SignedJWT jwt = new SignedJWT(header, claimsSet);

return jwt;
}

private static void signJwt(SignedJWT jwt, ECKey privateKey) throws JOSEException
{
// Create a signer to perform signing
JWSSigner signer = new ECDSASigner(privateKey);

// Sign the JWT
jwt.sign(signer);
}
}

This program outputs an Issuer-signed JWT as follows:

eyJ0eXAiOiJkYytzZC1qd3QiLCJhbGciOiJFUzI1NiJ9.eyJuaWNrbmFtZSI6IlRha2EiLCJfc2QiOlsiVmZ5X1MxeEVLQTc4c3Fsa19MMWlHLTZ6NnVkcFpwZGl2di1BMFBaNjNMVSIsImZsRWFDVnVEUWxidV9feDV4Vndpb25iVTIzdUtFZ1pZdW9NZG90Nnp4Y3MiXSwidmN0IjoiaHR0cHM6Ly9jcmVkZW50aWFscy5leGFtcGxlLmNvbS9pZGVudGl0eV9jcmVkZW50aWFsIn0.mYX7hh1jOzQLS_NlUEVgTaPG0sijFSYXGLqANDPQFJD3DAyS-NZSNVmHn_iQWu3Pa9Ev6DNnwOyAPTe-Eg4uEg

Decoding the header and payload of this Issuer-signed JWT using base64url yields the following JSONs:

{
"typ": "dc+sd-jwt",
"alg": "ES256"
}
{
"nickname": "Taka",
"_sd": [
"Vfy_S1xEKA78sqlk_L1iG-6z6udpZpdivv-A0PZ63LU",
"flEaCVuDQlbu__x5xVwionbU23uKEgZYuoMdot6zxcs"
],
"vct": "https://credentials.example.com/identity_credential"
}

In the payload, the nickname and vct claims appear with their names and values as-is, but the family_name and given_name claims are not included. Instead, the _sd array contains the digest values corresponding to the family_name and given_name claims.

Since the original values cannot be derived from the digest values, this Issuer-signed JWT alone does not reveal the information "family_name":"Kawasaki" or "given_name":"Takahiko".

SD-JWT

An SD-JWT consists of:

  • An Issuer-signed JWT
  • Zero or more Disclosures
  • An optional Key Binding JWT
<Issuer-signed JWT>~<Disclosure 1>~...~<Disclosure N>~[<Key Binding JWT>]

The SDJWT class in the SD-JWT library represents an SD-JWT and provides two constructors corresponding to the components described above:

SDJWT(String issuerSignedJwt, Collection<Disclosure> disclosures)
SDJWT(String issuerSignedJwt, Collection<Disclosure> disclosures, String keyBindingJwt)

In these constructors, the first argument is the serialized Issuer-signed JWT, and the second argument is a list of Disclosures.

NOTE: The argument representing the Issuer-signed JWT has the type String, not SignedJWT from the Nimbus JOSE + JWT library. Using String deliberately ensures that the SDJWT class does not depend on any specific JWT library.

By modifying the sample program mentioned earlier as shown below, you can generate an SD-JWT and output its serialized form.

      public static void main(String[] args) throws Exception
{
// Prepare selectively-disclosable claims as a list of Disclosures
List<Disclosure> disclosures = buildDisclosures();

// Prepare regular claims
Map<String, Object> regularClaims = buildRegularClaims();

// Generate a private key to sign the Issuer-signed JWT
ECKey privateKey = generatePrivateKey();

// Create the Issuer-signed JWT
String issuerSignedJwt =
buildIssuerSignedJwt(disclosures, regularClaims, privateKey);

- // Output the Issuer-signed JWT
- System.out.println(issuerSignedJwt);
+ // Create the SD-JWT
+ SDJWT sdjwt = new SDJWT(issuerSignedJwt, disclosures);
+
+ // Serialize the SD-JWT and output it
+ System.out.println(sdjwt);
}

When you run the modified program, it outputs an SD-JWT like the following:

eyJ0eXAiOiJkYytzZC1qd3QiLCJhbGciOiJFUzI1NiJ9.eyJuaWNrbmFtZSI6IlRha2EiLCJfc2QiOlsiQmxTRkNtLVJyLVc0UUZSV1pyb09keVNwV3BmUnVuTExGZDh4djB4VXJaVSIsIkk5Z2JrWGxNVEhHTThzTHNjenpVMDNlVEl2U1R2OWlnTlE5alpKLS1XYlkiXSwidmN0IjoiaHR0cHM6Ly9jcmVkZW50aWFscy5leGFtcGxlLmNvbS9pZGVudGl0eV9jcmVkZW50aWFsIn0.G365hmMY_Wp9vSVmifxKNXdVgxcXGuY_wevarIK95sgKFs6U8Me1a7WinSUVlQrWHDjT0AC9nM0GbYYV4hD6DQ~WyI5UFgwUXdUZXo3QlhOcjlkWVhpRVlnIiwiZmFtaWx5X25hbWUiLCJLYXdhc2FraSJd~WyJVTjByajNRTkJTeWJmSnNHY1NEYktBIiwiZ2l2ZW5fbmFtZSIsIlRha2FoaWtvIl0~

You can create an SDJWT instance from its serialized form by using the parse(String) method of the SDJWT class.

SDJWT sdjwt = SDJWT.parse(
"""\
eyJ0eXAiOiJkYytzZC1qd3QiLCJhbGciOiJFUzI1NiJ9.eyJua\
WNrbmFtZSI6IlRha2EiLCJfc2QiOlsiQmxTRkNtLVJyLVc0UUZ\
SV1pyb09keVNwV3BmUnVuTExGZDh4djB4VXJaVSIsIkk5Z2JrW\
GxNVEhHTThzTHNjenpVMDNlVEl2U1R2OWlnTlE5alpKLS1XYlk\
iXSwidmN0IjoiaHR0cHM6Ly9jcmVkZW50aWFscy5leGFtcGxlL\
mNvbS9pZGVudGl0eV9jcmVkZW50aWFsIn0.G365hmMY_Wp9vSV\
mifxKNXdVgxcXGuY_wevarIK95sgKFs6U8Me1a7WinSUVlQrWH\
DjT0AC9nM0GbYYV4hD6DQ~WyI5UFgwUXdUZXo3QlhOcjlkWVhp\
RVlnIiwiZmFtaWx5X25hbWUiLCJLYXdhc2FraSJd~WyJVTjBya\
jNRTkJTeWJmSnNHY1NEYktBIiwiZ2l2ZW5fbmFtZSIsIlRha2F\
oaWtvIl0~\
"""
);

SDObjectDecoder

You can recover the original claim names and values by computing the digest values of each Disclosure contained in the SD-JWT and matching them against the digest values in the payload of the Issuer-signed JWT.

Since the original claim values can themselves be JSON objects containing an _sd array, the digest matching process may need to be performed recursively. A JSON object representing an array element that contains only a single key "..." is also subject to this processing.

Considering additional checks such as the _sd_alg claim, this matching process is not particularly difficult, but writing the implementation from scratch each time can be tedious. For this reason, the SD-JWT library provides a utility class called SDObjectDecoder that performs this matching process.

By using one of the decode methods of SDObjectDecoder, you can execute the matching process (see the JavaDoc of SDObjectDecoder for details).

The following sample program decodes the payload of the Issuer-signed JWT in an SD-JWT specified by the first argument and displays it in JSON format.

import java.util.*;
import com.authlete.sd.*;
import com.google.gson.*;
import com.nimbusds.jwt.*;

public class DecodeExample
{
public static void main(String[] args) throws Exception
{
// Parse the first argument provided on the command line as an SD-JWT
SDJWT sdjwt = SDJWT.parse(args[0]);

// Convert the Issuer-signed JWT represented as a String
// to an instance of SignedJWT from the Nimbus JOSE + JWT library
SignedJWT jwt = SignedJWT.parse(sdjwt.getCredentialJwt());

// Convert the payload of the Issuer-signed JWT to a Map instance
// In most cases, this Map contains the _sd array
Map<String, Object> encodedPayload =
jwt.getJWTClaimsSet().toJSONObject();

// Extract the list of Disclosures contained in the SD-JWT
List<Disclosure> disclosures = sdjwt.getDisclosures();

// Create an SDObjectDecoder that will replace digest values
// in the payload with their corresponding claims
SDObjectDecoder decoder = new SDObjectDecoder();

// Compute the digest value of each Disclosure, match it with
// the digest values in the payload, and replace the digest
// values with the original claims
Map<String, Object> decodedPayload =
decoder.decode(encodedPayload, disclosures);

// Convert the Map instance to JSON
String decodedPayloadJson =
new GsonBuilder().setPrettyPrinting().create().toJson(decodedPayload);

// Output the JSON
System.out.println(decodedPayloadJson);
}
}

Assuming the shell variable SDJWT contains an SD-JWT:

SDJWT=eyJ0eXAiOiJkYytzZC1qd3QiLCJhbGciOiJFUzI1NiJ9.eyJuaWNrbmFtZSI6IlRha2EiLCJfc2QiOlsiQmxTRkNtLVJyLVc0UUZSV1pyb09keVNwV3BmUnVuTExGZDh4djB4VXJaVSIsIkk5Z2JrWGxNVEhHTThzTHNjenpVMDNlVEl2U1R2OWlnTlE5alpKLS1XYlkiXSwidmN0IjoiaHR0cHM6Ly9jcmVkZW50aWFscy5leGFtcGxlLmNvbS9pZGVudGl0eV9jcmVkZW50aWFsIn0.G365hmMY_Wp9vSVmifxKNXdVgxcXGuY_wevarIK95sgKFs6U8Me1a7WinSUVlQrWHDjT0AC9nM0GbYYV4hD6DQ~WyI5UFgwUXdUZXo3QlhOcjlkWVhpRVlnIiwiZmFtaWx5X25hbWUiLCJLYXdhc2FraSJd~WyJVTjByajNRTkJTeWJmSnNHY1NEYktBIiwiZ2l2ZW5fbmFtZSIsIlRha2FoaWtvIl0~

Running the sample program with $SDJWT as the first argument:

java -cp nimbus-jose-jwt.jar:sd-jwt.jar:gson.jar DecodeExample.java $SDJWT

produces the following JSON output:

{
"family_name": "Kawasaki",
"given_name": "Takahiko",
"vct": "https://credentials.example.com/identity_credential",
"nickname": "Taka"
}

Verification of SD-JWT VCs/VPs

Decoding the payload of an Issuer-signed JWT by matching it with Disclosures is straightforward using SDObjectDecoder. However, to trust the decoded content, you must verify the signature of the Issuer-signed JWT.

Additionally, if a Verifiable Presentation in SD-JWT format contains a Key Binding JWT (RFC 9901 Section 4.3, Key Binding JWT), you also need to verify the signature of that Key Binding JWT and check the sd_hash claim.

The VerificationTest.java included in the SD-JWT library tests serves as a sample for both generating SD-JWT–formatted Verifiable Credentials / Verifiable Presentations and verifying their signatures. You can refer to it as needed.

Other Topics

There are additional features not covered in this article, such as the SDObjectEncoder class for making the contents of a Map instance recursively selectively disclosable, and the Disclosure.toArrayElement() method for generating Map instances representing selectively disclosable array elements. For more details, please refer to the SD-JWT library documentation (README / JavaDoc) or the source code as needed.

Conclusion

Authlete has contributed to the development of many standards, and the SD-JWT specification (RFC 9901) is just one example. If you are interested in working on cutting-edge industry standards, be sure to check out Authlete’s Careers section!

--

--

Takahiko Kawasaki
Takahiko Kawasaki

Written by Takahiko Kawasaki

Co-founder and representative director of Authlete, Inc., working as a software engineer since 1997. https://www.authlete.com/