SD-JWT Issuance
The IDK provides tools for creating SD-JWT credentials with selective disclosure. This guide covers credential creation using the payload builder extensions and the SD-JWT service.
SD-JWT issuance produces a compact string in the format JWT~disclosure1~disclosure2~.... The JWT portion is a standard signed JWT whose payload contains hashed references (_sd digests) instead of the actual claim values. Each disclosure is a separate base64url-encoded tuple of [salt, claim_name, claim_value] that, when combined with the JWT, reveals the original claim. The holder receives the full set of disclosures at issuance time and later chooses which ones to present.
Building the Payload
SD-JWT issuance starts with building a JWT payload using SdJwtPayloadBuilder combined with selective disclosure extensions. Claims marked with claimSd() become selectively disclosable in the issued credential. Claims added with claim() or the standard JWT methods (iss, sub, exp) remain always visible in the signed JWT and cannot be hidden during presentation.
- Android/Kotlin
- iOS/Swift
import com.sphereon.sdjwt.dsl.sdJwtPayload
import com.sphereon.sdjwt.claimSd
import com.sphereon.sdjwt.subSd
import com.sphereon.sdjwt.dsl.objSd
// Build payload with selective disclosure
val payload = sdJwtPayload {
iss("https://issuer.example.com")
sub("user-123")
iat(System.currentTimeMillis() / 1000)
exp(System.currentTimeMillis() / 1000 + 86400)
// Selectively disclosable claims
claimSd("email", "user@example.com")
claimSd("given_name", "John")
claimSd("family_name", "Doe")
claimSd("age", 25)
// Regular claims (always visible)
claim("credential_type", "IdentityCredential")
}
import SphereonCrypto
import SphereonSdJwt
// Build payload with selective disclosure
let payload = SdJwtPayloadBuilder()
.iss("https://issuer.example.com")
.sub("user-123")
.iat(Int64(Date().timeIntervalSince1970))
.exp(Int64(Date().timeIntervalSince1970) + 86400)
// Selectively disclosable claims
.claimSd(name: "email", value: "user@example.com")
.claimSd(name: "given_name", value: "John")
.claimSd(name: "family_name", value: "Doe")
.claimSd(name: "age", value: 25)
// Regular claims (always visible)
.claim("credential_type", "IdentityCredential")
.build()
Selective Disclosure Extensions
You control which claims are disclosable by choosing the right builder function. Any claim marked with an Sd variant gets its own disclosure and a corresponding digest in the JWT's _sd array. Everything else stays in the clear.
The IDK provides several extension functions for marking claims as selectively disclosable:
| Function | Description |
|---|---|
claimSd(name, value) | Mark a custom claim as selectively disclosable |
subSd(value) | Mark the subject claim as selectively disclosable |
iatSd(value) | Mark the issued-at claim as selectively disclosable |
jtiSd(value) | Mark the JWT ID as selectively disclosable |
objSd(name) { } | Mark an entire nested object as selectively disclosable |
Security-sensitive claims like iss, aud, exp, nbf, and cnf should not be made selectively disclosable per RFC 9901 recommendations.
Issuing the SD-JWT
Once the payload is built, the SdJwtService signs the JWT, generates random salts for each disclosable claim, computes the disclosure digests, and assembles the final JWT~disclosure~... string. The issuer identity (key + context) determines which key signs the JWT.
Use SdJwtService to issue the credential:
- Android/Kotlin
- iOS/Swift
import com.sphereon.sdjwt.IssueSdJwtArgs
import com.sphereon.sdjwt.SdJwtSpec
// Get the SD-JWT service from the session
val sdJwtService = session.graph.sdJwtService
// Create issuer identifier from managed key
val issuer = ManagedOptsKeyInfo(
identifier = issuerKeyInfo,
context = IdentifierContext(
clientId = "issuer-001",
issuer = "https://issuer.example.com"
)
)
// Issue the SD-JWT
val result = sdJwtService.issueSdJwt(
IssueSdJwtArgs(
payload = payload,
issuer = issuer,
spec = SdJwtSpec.Default
)
)
if (result.isOk) {
val sdJwtResult = result.value
// Complete SD-JWT string: JWT~disclosure1~disclosure2~...
println("SD-JWT: ${sdJwtResult.sdJwt}")
// Individual components
println("JWT: ${sdJwtResult.jwt}")
println("Disclosures: ${sdJwtResult.disclosures.size}")
sdJwtResult.disclosures.forEach { disclosure ->
println(" ${disclosure.key}: ${disclosure.value}")
}
}
import SphereonSdJwt
// Get the SD-JWT service from the session
let sdJwtService = session.graph.sdJwtService
// Create issuer identifier from managed key
let issuer = ManagedOptsKeyInfo(
identifier: issuerKeyInfo,
context: IdentifierContext(
clientId: "issuer-001",
issuer: "https://issuer.example.com"
)
)
// Issue the SD-JWT
let result = try await sdJwtService.issueSdJwt(
args: IssueSdJwtArgs(
payload: payload,
issuer: issuer,
spec: SdJwtSpec.default
)
)
if result.isOk {
let sdJwtResult = result.value
// Complete SD-JWT string: JWT~disclosure1~disclosure2~...
print("SD-JWT: \(sdJwtResult.sdJwt)")
// Individual components
print("JWT: \(sdJwtResult.jwt)")
print("Disclosures: \(sdJwtResult.disclosures.count)")
for disclosure in sdJwtResult.disclosures {
print(" \(disclosure.key): \(disclosure.value)")
}
}
Nested Object Disclosure
When a claim value is a JSON object, you can make the entire object disclosable as a unit with objSd(). The holder either reveals the whole object or nothing. You can also nest claimSd() calls inside the object to make individual fields within it independently disclosable.
Use objSd() to make entire nested objects selectively disclosable:
- Android/Kotlin
- iOS/Swift
val payload = sdJwtPayload {
iss("https://issuer.example.com")
sub("user-123")
// Simple selectively disclosable claims
claimSd("given_name", "John")
claimSd("family_name", "Doe")
// Nested object as selectively disclosable
objSd("address") {
claim("street", "123 Main St")
claim("city", "Springfield")
claim("postal_code", "12345")
claim("country", "US")
}
// Nested object with inner selective disclosure
objSd("company") {
claim("name", "Acme Corp")
claim("department", "Engineering")
claimSd("employee_id", "EMP-12345") // Nested SD claim
}
}
let payload = SdJwtPayloadBuilder()
.iss("https://issuer.example.com")
.sub("user-123")
// Simple selectively disclosable claims
.claimSd(name: "given_name", value: "John")
.claimSd(name: "family_name", value: "Doe")
// Nested object as selectively disclosable
.objSd(name: "address") { builder in
builder.claim("street", "123 Main St")
builder.claim("city", "Springfield")
builder.claim("postal_code", "12345")
builder.claim("country", "US")
}
.build()
Decoy Digests
Decoy digests are fake entries added to the _sd array that do not correspond to any real disclosure. Without decoys, an observer who sees the JWT can count the digests and infer how many disclosable claims exist, even without knowing their values. Adding decoys pads the array to obscure the true count.
- Android/Kotlin
- iOS/Swift
import com.sphereon.sdjwt.minimumDigests
import com.sphereon.sdjwt.DecoyConfig
import com.sphereon.sdjwt.DecoyMode
// Using minimumDigests in the payload builder
val payload = sdJwtPayload {
iss("https://issuer.example.com")
claimSd("email", "user@example.com")
claimSd("phone", "+1234567890")
// Ensure at least 5 digests in _sd array (adds decoys if needed)
minimumDigests(5)
}
// Or configure via SdJwtSpec
val result = sdJwtService.issueSdJwt(
IssueSdJwtArgs(
payload = payload,
issuer = issuer,
spec = SdJwtSpec(
decoyConfig = DecoyConfig(
mode = DecoyMode.MINIMUM,
count = 5
)
)
)
)
// Configure decoys via SdJwtSpec
let result = try await sdJwtService.issueSdJwt(
args: IssueSdJwtArgs(
payload: payload,
issuer: issuer,
spec: SdJwtSpec(
decoyConfig: DecoyConfig(
mode: .minimum,
count: 5
)
)
)
)
Available decoy modes:
| Mode | Description |
|---|---|
NONE | No decoy digests (default) |
FIXED | Add exactly count decoy digests |
MINIMUM | Ensure at least count total digests |
RANDOM | Add random number of decoys up to count |
Hash Algorithm Configuration
The hash algorithm controls how disclosure digests in the _sd array are computed. SHA-256 is the default and the only algorithm required by RFC 9901. Setting includeAlgClaim = true adds the _sd_alg claim to the JWT payload, which tells verifiers which algorithm to use when re-hashing disclosures during verification.
Configure the hash algorithm used for disclosure digests:
- Android/Kotlin
- iOS/Swift
import com.sphereon.crypto.core.generic.DigestAlg
val result = sdJwtService.issueSdJwt(
IssueSdJwtArgs(
payload = payload,
issuer = issuer,
spec = SdJwtSpec(
digestAlg = DigestAlg.SHA256, // Default per RFC 9901
includeAlgClaim = true // Include _sd_alg in payload
)
)
)
let result = try await sdJwtService.issueSdJwt(
args: IssueSdJwtArgs(
payload: payload,
issuer: issuer,
spec: SdJwtSpec(
digestAlg: .sha256, // Default per RFC 9901
includeAlgClaim: true
)
)
)
Complete Issuance Example
- Android/Kotlin
- iOS/Swift
import com.sphereon.sdjwt.dsl.sdJwtPayload
import com.sphereon.crypto.core.generic.SignatureAlgorithm
import com.sphereon.sdjwt.*
class CredentialIssuer(
private val sdJwtService: SdJwtService,
private val keyManager: KeyManagerService
) {
suspend fun issueIdentityCredential(
subjectId: String,
givenName: String,
familyName: String,
email: String,
birthDate: String
): String {
// Generate or retrieve issuer key
val keyPair = keyManager.generateKeyAsync(
alg = SignatureAlgorithm.ECDSA_SHA256
)
val keyInfo = keyPair.joseToManagedKeyInfo()
val issuer = ManagedOptsKeyInfo(
identifier = keyInfo,
context = IdentifierContext(
clientId = "identity-issuer",
issuer = "https://issuer.example.com"
)
)
// Build payload with selective disclosure
val payload = sdJwtPayload {
iss("https://issuer.example.com")
sub(subjectId)
iat(System.currentTimeMillis() / 1000)
exp(System.currentTimeMillis() / 1000 + 31536000) // 1 year
// All personal data is selectively disclosable
claimSd("given_name", givenName)
claimSd("family_name", familyName)
claimSd("email", email)
claimSd("birth_date", birthDate)
// Credential metadata is always visible
claim("vct", "IdentityCredential")
claim("credential_schema", "https://schemas.example.com/identity/v1")
}
// Issue with decoy protection
val result = sdJwtService.issueSdJwt(
IssueSdJwtArgs(
payload = payload,
issuer = issuer,
spec = SdJwtSpec(
decoyConfig = DecoyConfig(
mode = DecoyMode.MINIMUM,
count = 8
)
)
)
)
return if (result.isOk) {
result.value.sdJwt
} else {
throw IllegalStateException("Issuance failed: ${result.error}")
}
}
}
import SphereonCrypto
import SphereonSdJwt
class CredentialIssuer {
private let sdJwtService: SdJwtService
private let keyManager: KeyManagerService
init(sdJwtService: SdJwtService, keyManager: KeyManagerService) {
self.sdJwtService = sdJwtService
self.keyManager = keyManager
}
func issueIdentityCredential(
subjectId: String,
givenName: String,
familyName: String,
email: String,
birthDate: String
) async throws -> String {
// Generate or retrieve issuer key
let keyPair = try await keyManager.generateKeyAsync(
alg: .ecdsaSha256
)
let keyInfo = keyPair.joseToManagedKeyInfo()
let issuer = ManagedOptsKeyInfo(
identifier: keyInfo,
context: IdentifierContext(
clientId: "identity-issuer",
issuer: "https://issuer.example.com"
)
)
let now = Int64(Date().timeIntervalSince1970)
// Build payload with selective disclosure
let payload = SdJwtPayloadBuilder()
.iss("https://issuer.example.com")
.sub(subjectId)
.iat(now)
.exp(now + 31536000) // 1 year
.claimSd(name: "given_name", value: givenName)
.claimSd(name: "family_name", value: familyName)
.claimSd(name: "email", value: email)
.claimSd(name: "birth_date", value: birthDate)
.claim("vct", "IdentityCredential")
.claim("credential_schema", "https://schemas.example.com/identity/v1")
.build()
// Issue with decoy protection
let result = try await sdJwtService.issueSdJwt(
args: IssueSdJwtArgs(
payload: payload,
issuer: issuer,
spec: SdJwtSpec(
decoyConfig: DecoyConfig(
mode: .minimum,
count: 8
)
)
)
)
guard result.isOk else {
throw NSError(domain: "Issuance", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Issuance failed"])
}
return result.value.sdJwt
}
}
Result Types
The issuance result contains all components of the SD-JWT:
| Property | Type | Description |
|---|---|---|
sdJwt | String | Complete SD-JWT string (JWT~disclosure1~disclosure2~...) |
jwt | String | The signed JWT component |
disclosures | List<Disclosure> | List of disclosure objects |
Each Disclosure contains:
| Property | Type | Description |
|---|---|---|
salt | String | Random salt value |
key | String | Claim name |
value | JsonElement | Claim value |
encoded | String | Base64url-encoded disclosure |
digest | String? | Hash of the encoded disclosure |