Skip to main content
Version: v0.25.0 (Latest)

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.

val holder: Oid4vciHolderService = session.graph.oid4vciHolderService

Configuration

The holder behavior is controlled through Oid4vciHolderConfig:

PropertyTypeDefaultDescription
clientIdString?nullOAuth 2.0 client identifier for token requests
preferredFormatString?nullPreferred credential format (e.g., dc+sd-jwt)
autoRequestNonceBooleantrueAutomatically fetch a c_nonce before creating proofs
defaultDeferredPollingIntervalInt5Seconds between deferred credential polling attempts
maxDeferredPollingAttemptsInt60Maximum number of polling attempts before giving up
requireVerifiedSignedMetadataBooleanfalseRequire 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.

// 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}")
}

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.

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}")
}
}

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.

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

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.

// 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"
)

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

TypeURNDescription
OID4VP Presentationurn:openid:dcp:iae:openid4vp_presentationPresent an existing credential to prove identity
Redirect to Weburn:openid:dcp:iae:redirect_to_webComplete authentication in a browser

IAE Flow

The IAE flow is a multi-step exchange between the holder and the authorization server's IAE endpoint:

// 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}")
}
}

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.

// 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!!)
}

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.

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")
}
}

If you need more control over timing or want to integrate polling into your own scheduling logic, use the lower-level requestDeferredCredential method directly:

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
}

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.

val notifyArgs = notifyWithRetryArgs {
endpoint(resolved.issuerMetadata.notificationEndpoint!!, accessToken)
notificationId(credentialResponse.notificationId!!)
event(CredentialNotificationEvent.CREDENTIAL_ACCEPTED)
retryPolicy(maxRetries = 3, initialBackoffMs = 1000L)
}
orchestrator.sendNotificationWithRetry(notifyArgs)

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:

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")
}
}

Session Tracking

The holder tracks issuance progress through Oid4vciHolderSession, which transitions through these states:

StatusDescription
CREATEDSession initialized
OFFER_RESOLVEDIssuer metadata fetched and offer validated
TOKEN_OBTAINEDAccess token acquired from authorization server
CREDENTIAL_REQUESTEDCredential request sent to issuer
CREDENTIAL_RECEIVEDCredential received (immediate issuance)
DEFERRED_PENDINGWaiting for deferred credential
COMPLETEDCredential stored and notification sent
FAILEDAn 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
)