Skip to main content

Presentation

Remote and browser presentation share one session shape: resolve → consent → submit. The SDK resolves the verifier's request, matches it against your stored credentials, waits for you to approve a selection, and then builds and sends a selectively-disclosed, holder-bound response. Kotlin exposes the session as a StateFlow<PresentationState>; Swift as an AsyncStream<PresentationState>.

The session lifecycle​

Every presentation walks the same states. The resolve and submit phases are driven by the SDK; the consent phase in the middle is yours — you inspect the resolved request, let the user choose, and call respond(...) or decline().

PhaseKotlin PresentationStateSwift PresentationState
resolveResolvingRequest → RequestResolved(request).resolvingRequest → .requestResolved(request)
consent(your UI — respond / decline)(your UI — respond / decline)
submitSubmitting.submitting
terminalCompleted(redirectUri, dcApiResponse) · Declined(redirectUri) · Failed(error).completed(redirectUri:, dcApiResponse:) · .declined(redirectUri:) · .failed(error)

state.isTerminal is true for Completed, Declined, and Failed.

Remote (URL / QR)​

A remote flow starts from an openid4vp://… deep link or a request_uri captured from a QR code. Start the session, then await RequestResolved:

import kotlinx.coroutines.flow.first

val session = wallet.presentation.start(requestUri) // openid4vp://… or a request_uri

val resolved = session.state.first {
it is PresentationState.RequestResolved || it is PresentationState.Failed
}
val request = (resolved as PresentationState.RequestResolved).request

Inspect who is asking and what answers it​

request.verifier tells you who the request is from, and request.queries lists what is being asked — each query carries the stored credentials that can answer it as candidates:

val v = request.verifier
// v.clientId, v.clientIdScheme, v.commonName (from the certificate), v.trusted
println("${v.commonName ?: v.clientId} · trusted=${v.trusted}")

for (query in request.queries) {
// query.queryId, query.required
for (candidate in query.candidates) {
// candidate.credentialId — a stored credential that satisfies this query
// candidate.disclosedPaths — the claims that would be revealed if chosen
}
}

request.satisfiable // true if every required query has at least one candidate
request.transactionData // optional transaction data to confirm (e.g. payment), or null

Respond and await the result​

After the user approves, hand the SDK a selection and await the terminal state. Completed.redirectUri is an optional post-success redirect supplied by the verifier:

session.respond(PresentationSelection.auto(request)) // or a user-chosen selection
// session.decline() instead, if the user refuses

val done = session.state.first { it.isTerminal }
when (done) {
is PresentationState.Completed -> done.redirectUri // optional redirect after success
is PresentationState.Declined -> done.redirectUri // verifier told `access_denied`; follow its redirect
is PresentationState.Failed -> done.error
else -> {}
}

What Failed carries​

Failed.error is a WalletError.Presentation (Kotlin) / PresentationError (Swift):

CaseWhen
InvalidRequest / .invalidRequestThe request object was malformed or failed an OpenID4VP §5 check (bad typ, client_id mismatch, missing/echoed wallet_nonce, expected_origins).
VerifierNotTrusted / .verifierNotTrustedA signed request failed reader-trust verification (chain, signature, or client_id scheme). Raised only when readerAnchorsDer is configured — see Reader trust.
QueryNotSatisfiable / .queryNotSatisfiableNo stored credential answers a required query.
SelectionIncomplete / .selectionIncompleteThe selection you passed to respond omitted a required query.
ResponseRejected / .responseRejectedThe verifier rejected the submitted response.
Unexpected / .unexpectedAny other error (transport, encoding), wrapping the cause.

A request that fails to resolve ends in Failed before RequestResolved — so a VerifierNotTrusted or InvalidRequest request never reaches your consent UI.

Selective disclosure & holder binding​

Only the claims covered by the request are disclosed — everything else in the credential stays hidden. The response is holder-bound: it is signed by the credential's holder key, which never leaves your SecureArea.

  • SD-JWT VC — a KB-JWT (Key Binding JWT) signed over the presentation, binding the disclosed claims to the audience and nonce.
  • mdoc — a DeviceResponse whose device signature is computed over the OpenID4VP SessionTranscript, binding the response to this exact request.

Verifier-driven unbound presentations (require_cryptographic_holder_binding)​

Holder binding is on by default. When the verifier sets require_cryptographic_holder_binding: false (OpenID4VP §6.1), the SDK presents the SD-JWT VC without a KB-JWT — an unbound presentation. This is verifier-driven and automatic; you take no action. Dropping the KB-JWT also drops its replay protection, and any transaction_data in the request forces binding back on regardless.

Transaction data (§8.4)​

When the verifier includes transaction_data, the SDK binds each entry to exactly one of its referenced credentials.

  • SD-JWT VC — a sha-256 hash of the entry goes into the KB-JWT automatically; no host action.
  • mdoc — supply a WalletConfig.presentation.mdocTransactionDataBinder, a function mapping a TransactionData entry to a device-signed data element (namespace, elementId, value) (the mapping is transaction-data-type specific). Without it, an mdoc-bound transaction_data is rejected with invalid_transaction_data.

The SDK also rejects malformed entries, unknown credential_ids, and — for mdoc — elements the MSO keyAuthorizations did not authorize.

Selection: automatic or explicit​

PresentationSelection.auto(request) picks all matching candidates for a multiple query and the first candidate otherwise — ideal once the user has approved an obvious match. For a real consent UI, build the selection explicitly by mapping each queryId to the list of credentialIds the user chose:

// Automatic: all candidates for a `multiple` query, first otherwise
session.respond(PresentationSelection.auto(request))

// Explicit: map each query to the chosen credential(s)
val chosen: Map<String, List<CredentialId>> = request.queries.associate { query ->
query.queryId to listOf(query.candidates.first().credentialId)
}
session.respond(PresentationSelection(chosen = chosen))

The value is a list because OpenID4VP DCQL multiple: true (§6.1) lets a single query return several credentials; a multiple: false (default) query still takes exactly one. QueryPresentation exposes multiple: Boolean so a consent UI can let the user pick several for a multiple query.

Presenting a credential issued with KeyUse.OneTime consumes one of its instances (for unlinkability); Rotate credentials are reused across presentations.

Digital Credentials API (browser)​

When the request arrives through the platform Digital Credentials API, the browser hands you the request object and the caller's origin directly. No HTTP is performed — the finished response object is returned to you so your app can pass it back to navigator.credentials.get():

val session = wallet.presentation.startDcApi(requestObject, origin)
// resolve → inspect → respond, exactly as above
val completed = session.state.first { it.isTerminal } as PresentationState.Completed
val response = completed.dcApiResponse // hand back to navigator.credentials.get()

An mdoc DC API response is origin-bound: the caller origin is folded into the DC API handover that the device signature covers, so the response cannot be replayed from another site.

Reader trust​

When you configure readerAnchorsDer in TrustConfig, signed request objects (JAR) are verified before the request is ever surfaced to you. Verification requires all of:

  1. the reader certificate chain validates to one of your configured anchors,
  2. the JWS signature on the request object verifies, and
  3. the client_id scheme (x509_san_dns or x509_hash) matches the signing certificate.

Only when all three hold is request.verifier.trusted == true. A signed request that fails any of them does not resolve — the session ends in Failed with a VerifierNotTrusted error. With no reader anchors configured, requests still resolve but arrive as untrusted (trusted == false), and it is your UI's responsibility to reflect that to the user.

Signed request objects are also checked against OpenID4VP §5: the JOSE typ must be oauth-authz-req+jwt, and the object's client_id must equal the one in the authorization request, prefix included. On request_uri_method=post the SDK sends a fresh wallet_nonce and terminates the request unless the verifier echoes it back.

Telling the verifier you refused (§8.5)​

When the user declines a remote presentation, the SDK POSTs an Authorization Error Response to the verifier's response_uri — the same endpoint a vp_token would have gone to:

error=access_denied&error_description=…&state=…

Without it the verifier's page just waits until it times out. The verifier may answer with a redirect_uri, which the SDK surfaces as Declined(redirectUri); when present you must send the user agent there.

access_denied deliberately covers both "the user refused" and "the wallet holds no matching credential", so a verifier cannot tell the two apart. Use Openid4VpClient.reportError(request, code) with the VpErrorCode taxonomy to report other conditions yourself.

:::note Nothing is reported over the DC API A Digital Credentials API request has no response_uri, and OpenID4VP §15.9.2 warns that returning protocol errors there can itself reveal that the user holds a matching credential — the platform only offers wallets that can satisfy the request. The refusal goes back to the platform instead. :::