OID4VCI Holder
The OID4VCI holder component enables wallet applications to receive credential offers, exchange tokens, and obtain verifiable credentials from issuers. This guide covers the full holder-side issuance flow, from parsing an offer to storing the credential.
The holder role in OID4VCI mirrors the holder role in OID4VP: the entity that possesses credentials in a wallet. In the issuance lifecycle, the holder receives credentials from an issuer; in the presentation lifecycle, the holder presents them to a verifier.
Obtaining the Holder
The holder service is the main entry point for all wallet-side OID4VCI operations. Grab it from the dependency graph once the OID4VCI module is loaded.
- Android/Kotlin
- iOS/Swift
val holder: Oid4vciHolderService = session.graph.oid4vciHolderService
let holder: Oid4vciHolderService = session.graph.oid4vciHolderService
Configuration
The holder behavior is controlled through Oid4vciHolderConfig:
| Property | Type | Default | Description |
|---|---|---|---|
clientId | String? | null | OAuth 2.0 client identifier for token requests |
preferredFormat | String? | null | Preferred credential format (e.g., dc+sd-jwt) |
autoRequestNonce | Boolean | true | Automatically fetch a c_nonce before creating proofs |
defaultDeferredPollingInterval | Int | 5 | Seconds between deferred credential polling attempts |
maxDeferredPollingAttempts | Int | 60 | Maximum number of polling attempts before giving up |
requireVerifiedSignedMetadata | Boolean | false | Require cryptographic verification of signed issuer metadata |
When autoRequestNonce is enabled (the default), the holder automatically fetches a fresh nonce from the issuer's nonce endpoint before creating proofs. This simplifies the flow since you don't need to manage nonces manually.
When requireVerifiedSignedMetadata is enabled, the holder rejects issuer metadata that advertises signed_metadata but provides an invalid or missing signature. This is recommended for production wallets that need to verify issuer authenticity.
Parsing Credential Offers
When a user scans a QR code or follows a deep link, the wallet receives a credential offer URI. The URI uses the openid-credential-offer:// scheme and contains either an inline JSON payload or a reference URL pointing to one. The first step is to parse it into a structured object.
A credential offer contains three key pieces of information: the issuer identifier, the credential configuration IDs indicating which credentials are being offered, and the grant information describing how to obtain an access token. The grant object determines the rest of the flow: a pre-authorized code means the issuer has already approved issuance, while an authorization code means the holder will need to authenticate first.
- Android/Kotlin
- iOS/Swift
// Raw offer from QR code or deep link
val rawOffer = "openid-credential-offer://credential_offer=..."
val offer = holder.parseCredentialOffer(rawOffer)
// Inspect what's being offered
println("Issuer: ${offer.credentialIssuer}")
println("Credentials: ${offer.credentialConfigurationIds}")
// Check which grant types are available
offer.grants?.preAuthorizedCode?.let { grant ->
println("Pre-authorized code available")
println("TX code required: ${grant.txCodeRequired}")
}
offer.grants?.authorizationCode?.let { grant ->
println("Authorization code available")
println("Issuer state: ${grant.issuerState}")
}
// Raw offer from QR code or deep link
let rawOffer = "openid-credential-offer://credential_offer=..."
let offer = try await holder.parseCredentialOffer(rawOffer: rawOffer)
// Inspect what's being offered
print("Issuer: \(offer.credentialIssuer)")
print("Credentials: \(offer.credentialConfigurationIds)")
// Check which grant types are available
if let preAuth = offer.grants?.preAuthorizedCode {
print("Pre-authorized code available")
print("TX code required: \(preAuth.txCodeRequired)")
}
if let authCode = offer.grants?.authorizationCode {
print("Authorization code available")
print("Issuer state: \(authCode.issuerState ?? "")")
}
Resolving the Offer
After parsing, the wallet needs to discover the issuer's capabilities. Resolving the offer fetches the issuer's metadata from its .well-known/openid-credential-issuer endpoint. This tells the wallet what credential formats are supported, which endpoints to use, and what proof types are accepted.
- Android/Kotlin
- iOS/Swift
val resolved = holder.resolveCredentialOffer(offer)
// Access issuer metadata
val metadata = resolved.issuerMetadata
println("Credential endpoint: ${metadata.credentialEndpoint}")
println("Nonce endpoint: ${metadata.nonceEndpoint}")
// Inspect available credential configurations
metadata.credentialConfigurationsSupported.forEach { (id, config) ->
println("$id: format=${config.format}, scope=${config.scope}")
config.display?.firstOrNull()?.let { display ->
println(" Name: ${display.name}, Logo: ${display.logo?.uri}")
}
}
let resolved = try await holder.resolveCredentialOffer(offer: offer)
// Access issuer metadata
let metadata = resolved.issuerMetadata
print("Credential endpoint: \(metadata.credentialEndpoint)")
print("Nonce endpoint: \(metadata.nonceEndpoint ?? "")")
// Inspect available credential configurations
for (id, config) in metadata.credentialConfigurationsSupported {
print("\(id): format=\(config.format), scope=\(config.scope ?? "")")
if let display = config.display?.first {
print(" Name: \(display.name ?? ""), Logo: \(display.logo?.uri ?? "")")
}
}
The resolved offer combines the original offer with the fetched metadata, providing everything the wallet needs to proceed with the token exchange.
Obtaining an Access Token
No credential request will succeed without a valid access token. The token proves to the issuer that the wallet is authorized to receive the offered credentials. The method for obtaining one depends on which grant type the offer includes.
Pre-Authorized Code Flow
This is the simplest flow. The issuer has already prepared a code, and the wallet exchanges it for a token. If the issuer requires a transaction code, the wallet must prompt the user for it first.
- Android/Kotlin
- iOS/Swift
val grant = offer.grants!!.preAuthorizedCode!!
// If a transaction code is required, prompt the user
val txCode = if (grant.txCodeRequired) {
promptUserForPin() // Show PIN entry UI
} else null
val tokenResponse = holder.exchangePreAuthorizedCode(
tokenEndpoint = resolved.tokenEndpoint,
code = grant.preAuthorizedCode,
txCode = txCode,
clientId = "my-wallet-app"
)
val accessToken = tokenResponse.accessToken
val cNonce = tokenResponse.cNonce // For proof creation
let grant = offer.grants!.preAuthorizedCode!
// If a transaction code is required, prompt the user
let txCode: String? = grant.txCodeRequired ? await promptUserForPin() : nil
let tokenResponse = try await holder.exchangePreAuthorizedCode(
tokenEndpoint: resolved.tokenEndpoint,
code: grant.preAuthorizedCode,
txCode: txCode,
clientId: "my-wallet-app"
)
let accessToken = tokenResponse.accessToken
let cNonce = tokenResponse.cNonce // For proof creation
Authorization Code Flow
When the offer uses an authorization code grant, the wallet redirects the user to the authorization server for authentication. This is a three-step process: build the authorization URL, let the user log in via a browser, then exchange the resulting code for tokens. PKCE is used throughout to prevent authorization code interception.
- Android/Kotlin
- iOS/Swift
// 1. Select the authorization server
val authServer = holder.selectAuthorizationServer(
issuerMetadata = resolved.issuerMetadata,
preferredAuthorizationServer = null // Use default
)
// 2. Build the authorization URL with PKCE
val authRequest = holder.buildAuthorizationRequest(
authorizationEndpoint = authServer.authorizationEndpoint,
clientId = "my-wallet-app",
redirectUri = "myapp://callback",
scope = "openid credential_identity",
issuerState = offer.grants?.authorizationCode?.issuerState
)
// authRequest.url - Open in browser
// authRequest.codeVerifier - Store for token exchange
// 3. After user authenticates and is redirected back
val tokenResponse = holder.exchangeAuthorizationCode(
tokenEndpoint = authServer.tokenEndpoint,
code = callbackCode, // From redirect callback
codeVerifier = authRequest.codeVerifier,
redirectUri = "myapp://callback",
clientId = "my-wallet-app"
)
// 1. Select the authorization server
let authServer = try await holder.selectAuthorizationServer(
issuerMetadata: resolved.issuerMetadata,
preferredAuthorizationServer: nil // Use default
)
// 2. Build the authorization URL with PKCE
let authRequest = try await holder.buildAuthorizationRequest(
authorizationEndpoint: authServer.authorizationEndpoint,
clientId: "my-wallet-app",
redirectUri: "myapp://callback",
scope: "openid credential_identity",
issuerState: offer.grants?.authorizationCode?.issuerState
)
// authRequest.url - Open in browser
// authRequest.codeVerifier - Store for token exchange
// 3. After user authenticates and is redirected back
let tokenResponse = try await holder.exchangeAuthorizationCode(
tokenEndpoint: authServer.tokenEndpoint,
code: callbackCode, // From redirect callback
codeVerifier: authRequest.codeVerifier,
redirectUri: "myapp://callback",
clientId: "my-wallet-app"
)
Interactive Authorization Exchange (IAE)
Unlike the standard authorization code flow where the user just logs in, IAE adds an extra challenge step controlled by the issuer. Some issuers require additional verification before issuing a credential. Interactive Authorization Exchange (IAE) is an OID4VCI 1.1 mechanism that allows the authorization server to challenge the holder with an interactive step, typically presenting an existing credential via OID4VP, or completing a web-based authentication flow.
This creates a "present-to-obtain" pattern: for example, a holder might need to present their national ID credential (via OID4VP) in order to receive a university degree credential (via OID4VCI).
Interaction Types
| Type | URN | Description |
|---|---|---|
| OID4VP Presentation | urn:openid:dcp:iae:openid4vp_presentation | Present an existing credential to prove identity |
| Redirect to Web | urn:openid:dcp:iae:redirect_to_web | Complete authentication in a browser |
IAE Flow
The IAE flow is a multi-step exchange between the holder and the authorization server's IAE endpoint:
- Android/Kotlin
- iOS/Swift
// 1. Initiate IAE using the DSL
val initiateArgs = initiateIaeArgs {
iaeEndpoint("https://auth.example.com/iae")
clientId("my-wallet-app")
redirectUri("myapp://callback")
interactionTypes(
IaeInteractionType.OPENID4VP_PRESENTATION,
IaeInteractionType.REDIRECT_TO_WEB
)
scope("openid credential_identity")
}
val iaeResult = holder.initiateIae(initiateArgs)
// 2. Handle the result
when (iaeResult) {
is IaeHolderResult.InteractionRequired -> {
when (iaeResult.type) {
Oid4vciInteractionTypes.OPENID4VP_PRESENTATION -> {
// Present a credential via OID4VP
val vpRequest = iaeResult.openid4vpRequest!!
val vpResponse = performOid4vpPresentation(vpRequest)
// 3. Follow up with the VP response
val followUpArgs = followUpIaeArgs {
iaeEndpoint("https://auth.example.com/iae")
authSession(iaeResult.authSession)
vpResponse(vpResponse)
}
val followUp = holder.followUpIae(followUpArgs)
// followUp is another IaeHolderResult - may be AuthorizationCode or another interaction
}
Oid4vciInteractionTypes.REDIRECT_TO_WEB -> {
// Redirect to browser for web-based auth
openBrowser(iaeResult.requestUri!!)
}
}
}
is IaeHolderResult.AuthorizationCode -> {
// Exchange the code for an access token
val tokenResponse = holder.exchangeAuthorizationCode(
tokenEndpoint = authServer.tokenEndpoint,
code = iaeResult.code,
codeVerifier = codeVerifier,
redirectUri = "myapp://callback",
clientId = "my-wallet-app"
)
}
is IaeHolderResult.Error -> {
showError("IAE failed: ${iaeResult.error} - ${iaeResult.errorDescription}")
}
}
// 1. Initiate IAE
let iaeResult = try await holder.initiateIae(
args: InitiateIaeArgs(
iaeEndpoint: "https://auth.example.com/iae",
clientId: "my-wallet-app",
redirectUri: "myapp://callback",
interactionTypesSupported: [
Oid4vciInteractionTypes.OPENID4VP_PRESENTATION,
Oid4vciInteractionTypes.REDIRECT_TO_WEB
],
scope: "openid credential_identity"
)
)
// 2. Handle the result
switch iaeResult {
case let interaction as IaeHolderResult.InteractionRequired:
if interaction.type == Oid4vciInteractionTypes.OPENID4VP_PRESENTATION {
// Present a credential via OID4VP
let vpRequest = interaction.openid4vpRequest!
let vpResponse = try await performOid4vpPresentation(vpRequest)
// 3. Follow up with the VP response
let followUp = try await holder.followUpIae(
args: FollowUpIaeArgs(
iaeEndpoint: "https://auth.example.com/iae",
authSession: interaction.authSession,
openid4vpResponse: vpResponse
)
)
} else if interaction.type == Oid4vciInteractionTypes.REDIRECT_TO_WEB {
openBrowser(url: interaction.requestUri!)
}
case let authCode as IaeHolderResult.AuthorizationCode:
// Exchange the code for an access token
let tokenResponse = try await holder.exchangeAuthorizationCode(
tokenEndpoint: authServer.tokenEndpoint,
code: authCode.code,
codeVerifier: codeVerifier,
redirectUri: "myapp://callback",
clientId: "my-wallet-app"
)
case let error as IaeHolderResult.Error:
showError("IAE failed: \(error.error) - \(error.errorDescription ?? "")")
default:
break
}
The IAE flow may require multiple rounds of interaction. Each InteractionRequired result includes an authSession token that must be passed back in the follow-up request to maintain the session. The expiresIn field indicates how long the session remains valid.
Requesting a Credential
With an access token in hand, the wallet can request the credential. This involves two steps: creating a proof of possession and sending the credential request.
The proof demonstrates to the issuer that the wallet controls the key that the credential should be bound to. It is a signed JWT containing the issuer URL and the nonce from the token response. The issuer checks this signature to confirm the credential will be bound to a key the wallet actually holds, preventing credential theft if the access token were leaked.
- Android/Kotlin
- iOS/Swift
// 1. Create the proof of possession
val proofArgs = createProofArgs {
issuerUrl(offer.credentialIssuer)
nonce(tokenResponse.cNonce!!)
signingKey(walletKeyId, JwaAlgorithm.ES256)
clientId("my-wallet-app")
}
val proof = holder.createCredentialRequestProof(proofArgs)
// 2. Request the credential
val requestArgs = requestCredentialArgs {
endpoint(resolved.issuerMetadata.credentialEndpoint, accessToken)
credentialConfigurationId(offer.credentialConfigurationIds.first())
proof {
proofType = "jwt"
jwt = proof.jwt
}
}
val credentialResponse = holder.requestCredential(requestArgs)
// 3. Handle the response
if (credentialResponse.credential != null) {
// Credential issued immediately
storeCredential(credentialResponse.credential!!)
} else if (credentialResponse.transactionId != null) {
// Deferred issuance - credential not ready yet
handleDeferredIssuance(credentialResponse.transactionId!!)
}
// 1. Create the proof of possession
let proof = try await holder.createCredentialRequestProof(
issuerUrl: offer.credentialIssuer,
cNonce: tokenResponse.cNonce,
signingKeyId: walletKeyId, // Key to bind the credential to
signingAlgorithm: "ES256",
clientId: "my-wallet-app"
)
// 2. Request the credential
let credentialResponse = try await holder.requestCredential(
credentialEndpoint: resolved.issuerMetadata.credentialEndpoint,
accessToken: accessToken,
configId: offer.credentialConfigurationIds.first!,
proof: proof
)
// 3. Handle the response
if let credential = credentialResponse.credential {
// Credential issued immediately
storeCredential(credential)
} else if let transactionId = credentialResponse.transactionId {
// Deferred issuance - credential not ready yet
handleDeferredIssuance(transactionId)
}
Deferred Credential Retrieval
A credential is not always ready the moment you ask for it. The issuer may need to run background checks, wait for manual approval, or perform asynchronous signing. In these cases, the issuer returns a transactionId instead of a credential, and the wallet must poll the deferred credential endpoint until the credential is ready. The IDK provides a flow orchestrator that handles the polling loop automatically.
- Android/Kotlin
- iOS/Swift
val orchestrator: Oid4vciIssuanceFlowOrchestrator = session.graph.oid4vciIssuanceFlowOrchestrator
val pollArgs = pollDeferredArgs {
endpoint(resolved.issuerMetadata.deferredCredentialEndpoint!!, accessToken)
transactionId(transactionId)
sessionId(sessionId)
polling(interval = 5, maxAttempts = 60) // 5s between attempts, give up after 5 min
}
val pollResult = orchestrator.pollDeferredCredential(pollArgs)
when (pollResult) {
is PollDeferredCredentialResult.Ready -> {
storeCredential(pollResult.credential)
}
is PollDeferredCredentialResult.Exhausted -> {
showError("Credential issuance timed out")
}
}
let orchestrator: Oid4vciIssuanceFlowOrchestrator = session.graph.oid4vciIssuanceFlowOrchestrator
let pollResult = try await orchestrator.pollDeferredCredential(
deferredCredentialEndpoint: resolved.issuerMetadata.deferredCredentialEndpoint!,
accessToken: accessToken,
transactionId: transactionId,
interval: 5, // seconds between attempts
maxAttempts: 60 // give up after 5 minutes
)
switch pollResult {
case let .ready(credential):
storeCredential(credential)
case .exhausted:
showError("Credential issuance timed out")
}
If you need more control over timing or want to integrate polling into your own scheduling logic, use the lower-level requestDeferredCredential method directly:
- Android/Kotlin
- iOS/Swift
val response = holder.requestDeferredCredential(
deferredEndpoint = resolved.issuerMetadata.deferredCredentialEndpoint!!,
accessToken = accessToken,
transactionId = transactionId
)
if (response.credential != null) {
// Credential is ready
} else {
// Still pending - try again later
// response.interval indicates suggested wait time in seconds
}
let response = try await holder.requestDeferredCredential(
deferredEndpoint: resolved.issuerMetadata.deferredCredentialEndpoint!,
accessToken: accessToken,
transactionId: transactionId
)
if let credential = response.credential {
// Credential is ready
} else {
// Still pending - try again later
// response.interval indicates suggested wait time in seconds
}
Sending Notifications
Notifications are optional but recommended. After receiving a credential, the wallet should notify the issuer about the outcome: whether the credential was accepted, rejected, or deleted. This helps issuers track success rates and handle failures.
- Android/Kotlin
- iOS/Swift
val notifyArgs = notifyWithRetryArgs {
endpoint(resolved.issuerMetadata.notificationEndpoint!!, accessToken)
notificationId(credentialResponse.notificationId!!)
event(CredentialNotificationEvent.CREDENTIAL_ACCEPTED)
retryPolicy(maxRetries = 3, initialBackoffMs = 1000L)
}
orchestrator.sendNotificationWithRetry(notifyArgs)
try await holder.sendNotification(
notificationEndpoint: resolved.issuerMetadata.notificationEndpoint!,
accessToken: accessToken,
notificationId: credentialResponse.notificationId!,
event: .credentialAccepted,
description: nil
)
The notifyWithRetryArgs DSL configures automatic retry with exponential backoff, which is recommended for production use. The retryPolicy defaults to 3 retries with 1 second initial backoff.
End-to-End Flow Orchestration
The individual steps above give you full control, but most wallet implementations follow the same sequence. For the common case of requesting a credential through the entire pre-authorized code flow, the orchestrator provides a single method that handles token exchange, proof creation, credential request, and deferred polling:
- Android/Kotlin
- iOS/Swift
val flowArgs = credentialFlowArgs {
sessionId(sessionId)
endpoint(resolved.issuerMetadata.credentialEndpoint, accessToken)
issuerUrl(offer.credentialIssuer)
signingKey(walletKeyId, JwaAlgorithm.ES256)
credentialConfigurationId(offer.credentialConfigurationIds.first())
nonceEndpoint(resolved.issuerMetadata.nonceEndpoint!!)
deferredEndpoint(resolved.issuerMetadata.deferredCredentialEndpoint!!)
notificationEndpoint(resolved.issuerMetadata.notificationEndpoint!!)
}
val result = orchestrator.requestCredentialWithFlow(flowArgs)
when (result) {
is CredentialFlowResult.Immediate -> {
storeCredential(result.credential)
}
is CredentialFlowResult.DeferredCompleted -> {
storeCredential(result.credential)
}
is CredentialFlowResult.DeferredExhausted -> {
showError("Issuance timed out after polling")
}
}
let result = try await orchestrator.requestCredentialWithFlow(
tokenEndpoint: resolved.tokenEndpoint,
credentialEndpoint: resolved.issuerMetadata.credentialEndpoint,
deferredCredentialEndpoint: resolved.issuerMetadata.deferredCredentialEndpoint,
preAuthorizedCode: grant.preAuthorizedCode,
txCode: userPin,
clientId: "my-wallet-app",
credentialConfigurationId: offer.credentialConfigurationIds.first!,
signingKeyId: walletKeyId,
signingAlgorithm: "ES256",
issuerUrl: offer.credentialIssuer
)
switch result {
case let .immediate(credential):
storeCredential(credential)
case let .deferredCompleted(credential):
storeCredential(credential)
case .deferredExhausted:
showError("Issuance timed out after polling")
}
Session Tracking
The holder tracks issuance progress through Oid4vciHolderSession, which transitions through these states:
| Status | Description |
|---|---|
CREATED | Session initialized |
OFFER_RESOLVED | Issuer metadata fetched and offer validated |
TOKEN_OBTAINED | Access token acquired from authorization server |
CREDENTIAL_REQUESTED | Credential request sent to issuer |
CREDENTIAL_RECEIVED | Credential received (immediate issuance) |
DEFERRED_PENDING | Waiting for deferred credential |
COMPLETED | Credential stored and notification sent |
FAILED | An error occurred during issuance |
Sessions are persisted through the Oid4vciHolderSessionStore, allowing the wallet to resume interrupted flows after app restarts or network failures.
Data Types
These are the primary data classes you will work with on the holder side. They are listed here for quick reference.
CredentialOffer
data class CredentialOffer(
val credentialIssuer: String, // Issuer identifier (HTTPS URL)
val credentialConfigurationIds: List<String>, // Which credentials are offered
val grants: CredentialOfferGrants? = null // How to obtain an access token
)
CredentialResponse
data class CredentialResponse(
val credential: JsonElement? = null, // Issued credential (if immediate)
val transactionId: String? = null, // For deferred issuance
val cNonce: String? = null, // Fresh nonce for subsequent requests
val cNonceExpiresIn: Int? = null, // Nonce expiry in seconds
val notificationId: String? = null, // For sending notifications
val interval: Int? = null // Suggested deferred polling interval
)