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().
| Phase | Kotlin PresentationState | Swift PresentationState |
|---|---|---|
| resolve | ResolvingRequest → RequestResolved(request) | .resolvingRequest → .requestResolved(request) |
| consent | (your UI — respond / decline) | (your UI — respond / decline) |
| submit | Submitting | .submitting |
| terminal | Completed(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:
- Kotlin
- Swift
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
let session = wallet.presentation.start(requestUri) // openid4vp://… or a request_uri
var request: PresentationRequest?
for await state in session.states {
if case let .requestResolved(resolved) = state { request = resolved; break }
if state.isTerminal { break } // .failed before it ever resolved
}
guard let request else { return }
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:
- Kotlin
- Swift
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
let v = request.verifier
// v.clientId, v.clientIdScheme, v.commonName (from the certificate), v.trusted
print("\(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 nil
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:
- Kotlin
- Swift
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 -> {}
}
session.respond(PresentationSelection.auto(request)) // or a user-chosen selection
// session.decline() instead, if the user refuses
for await state in session.states {
switch state {
case let .completed(redirectUri, _): _ = redirectUri // optional redirect after success
case let .declined(redirectUri): _ = redirectUri // verifier told `access_denied`; follow its redirect
case let .failed(error): _ = error
default: break
}
if state.isTerminal { break }
}
What Failed carries
Failed.error is a WalletError.Presentation (Kotlin) / PresentationError (Swift):
| Case | When |
|---|---|
InvalidRequest / .invalidRequest | The request object was malformed or failed an OpenID4VP §5 check (bad typ, client_id mismatch, missing/echoed wallet_nonce, expected_origins). |
VerifierNotTrusted / .verifierNotTrusted | A signed request failed reader-trust verification (chain, signature, or client_id scheme). Raised only when readerAnchorsDer is configured — see Reader trust. |
QueryNotSatisfiable / .queryNotSatisfiable | No stored credential answers a required query. |
SelectionIncomplete / .selectionIncomplete | The selection you passed to respond omitted a required query. |
ResponseRejected / .responseRejected | The verifier rejected the submitted response. |
Unexpected / .unexpected | Any 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
DeviceResponsewhose device signature is computed over the OpenID4VPSessionTranscript, 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-256hash of the entry goes into the KB-JWT automatically; no host action. - mdoc — supply a
WalletConfig.presentation.mdocTransactionDataBinder, a function mapping aTransactionDataentry to a device-signed data element(namespace, elementId, value)(the mapping is transaction-data-type specific). Without it, an mdoc-boundtransaction_datais rejected withinvalid_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:
- Kotlin
- Swift
// 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))
// Automatic: all candidates for a `multiple` query, first otherwise
session.respond(PresentationSelection.auto(request))
// Explicit: map each query to the chosen credential(s)
var chosen: [String: [CredentialId]] = [:]
for query in request.queries {
chosen[query.queryId] = [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():
- Kotlin
- Swift
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()
let session = wallet.presentation.startDcApi(requestObject, origin: origin)
// resolve → inspect → respond, exactly as above
var response: String?
for await state in session.states {
if case let .completed(_, dcApiResponse) = state { response = dcApiResponse; break }
if state.isTerminal { break }
}
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:
- the reader certificate chain validates to one of your configured anchors,
- the JWS signature on the request object verifies, and
- the
client_idscheme (x509_san_dnsorx509_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.
:::