Digital Credentials API explained: the browser as the wallet chooser
The W3C Digital Credentials API, usually shortened to DC API, lets a web page ask for a credential through the browser instead of through a link or a QR code. The page calls navigator.credentials.get() with a digital member, the browser and the operating system decide which wallets can answer, the holder picks one, and the answer comes back to the same page — no custom URI scheme, no QR code the site has to render itself, and no guessing about which wallets the holder has installed.
This page covers what that API carries, how OpenID4VP and ISO 18013-7 ride on top of it, and the one security property that makes it worth the trouble: the browser tells the wallet which origin is asking, and the page cannot lie about it.
What the Digital Credentials API is
The API is deliberately thin. It is a transport and chooser, not a credential protocol: it knows how to reach a wallet and how to return an opaque blob, and it leaves the request and response formats to a protocol identified by a string.
Three things the browser adds that a link or a QR code cannot:
- The Origin. The wallet is told which origin made the request, as authenticated by the browser. A page cannot claim to be someone else.
- The chooser. The platform, not the verifier, decides which wallets are offered, and the holder picks. A verifier never learns which wallets the holder did not pick.
- Transient activation. The call only works from a user gesture, so a page cannot silently probe for credentials in the background.
The problem it solves
Before the DC API, a web page had two ways to reach a wallet, and both leak.
| Invocation | What goes wrong |
|---|---|
A custom URI scheme such as openid4vp:// | Any app that registered the scheme can claim the request. The wallet has only the request itself to judge who sent it, and the holder may see an app chooser listing apps that are not wallets at all |
| A QR code the verifier renders | Works cross-device, but the verifier has to build the QR, the polling and the session plumbing, and nothing in the flow proves which site the holder is actually looking at |
Both also make wallet discovery a guessing game. A site that wants to reach EUDI wallets, an mDL holder and its own wallet either renders several buttons or picks one and loses the rest. Under the DC API the page states which protocols it can speak and the platform matches that against what the holder has.
Calling the API
The entry point is navigator.credentials.get() with a digital member holding one or more requests. Each request is a protocol identifier plus a data object whose shape that protocol defines.
const protocol = "openid4vp-v1-unsigned";
// Must be called from a user gesture: the API requires transient activation.
button.addEventListener("click", async () => {
// Feature detection. An unknown protocol returns false rather than throwing.
if (typeof DigitalCredential === "undefined" ||
!DigitalCredential.userAgentAllowsProtocol(protocol)) {
showQrCodeFallback();
return;
}
let credential;
try {
credential = await navigator.credentials.get({
digital: {
requests: [
{ protocol, data: request }
]
}
});
} catch (err) {
// NotAllowedError: the holder cancelled or declined, or there was no transient
// activation. TypeError: none of the requested protocols is supported.
showQrCodeFallback();
return;
}
// credential.protocol says which request was answered.
// credential.data holds the protocol's response, opaque to the browser.
// A wallet-side protocol error arrives here, on a *resolved* promise.
if (credential.data.error) {
showError(credential.data.error);
return;
}
await fetch("/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
});
Three things in that sketch are easy to leave out and expensive to leave out. The call needs transient activation, so it has to hang off a real user gesture. The promise rejects when the holder cancels or declines (deliberately indistinguishable, both a NotAllowedError) and with a TypeError when none of the requested protocols is supported, so there has to be a catch or the fallback never runs. And a wallet-side protocol error arrives on a resolved promise, in data.error — see The response — so a verifier that only handles the rejection path will POST an error object to its backend as if it were a presentation.
Listing more than one entry in requests lets a verifier offer the same ask over several protocols — an OpenID4VP request and an org-iso-mdoc request side by side — and let the platform pick the one the chosen wallet supports. DigitalCredential.userAgentAllowsProtocol() is the cheap way to find out what this browser will accept before building anything, and it is what a well-behaved verifier uses to fall back to a QR code rather than showing the holder an error.
navigator.credentials.create() is the mirror image for issuance, with openid4vci-v1 as its protocol identifier. It is newer and less widely shipped than presentation.
The API is gated by the digital-credentials-get policy-controlled feature, and digital-credentials-create for issuance. A page that embeds a verification service in an iframe has to grant it explicitly with <iframe src="https://verifier-service.example" allow="digital-credentials-get">, otherwise the call in the frame is rejected.
Protocol identifiers
The protocol string is not free-form. The identifiers are a closed WebIDL enumeration, extended as user agents adopt new protocols. A user agent silently filters out any request whose protocol it does not support, and the call only rejects, with a TypeError, when no supported request is left. That is what makes userAgentAllowsProtocol() meaningful, and it is why the version is baked into the string — a wallet never has to infer which version of a protocol a request is by pattern-matching its parameters.
| Identifier | Direction | What it names |
|---|---|---|
openid4vp-v1-unsigned | Presentation | An OpenID4VP 1.0 request sent unsigned, authenticated by the Origin alone (Appendix A.3.1) |
openid4vp-v1-signed | Presentation | An OpenID4VP 1.0 request as a JWS Compact Serialization (Appendix A.3.2.1) |
openid4vp-v1-multisigned | Presentation | The same request signed several times, as a JWS JSON Serialization, one signature per trust framework (Appendix A.3.2.2) |
org-iso-mdoc | Presentation | An ISO/IEC 18013-5 DeviceRequest carried the way ISO/IEC 18013-7:2025 Annex C defines |
openid4vci-v1 | Issuance | An OpenID4VCI 1.0 issuance request, used with navigator.credentials.create() |
OpenID4VP over the DC API
OpenID4VP's introduction declares Appendix A self-contained: apart from what it explicitly references, an implementer of OpenID4VP over the DC API can ignore the rest of the specification. Only a subset of the authorization request parameters applies — client_id, response_type, response_mode, nonce, client_metadata, request, transaction_data, dcql_query and verifier_info, plus whatever a Client Identifier Prefix adds. A wallet that does not support transaction_data must reject a request that carries it rather than ignore it. Everything else is ignored, including state: there is no redirect to correlate, so the verifier keeps its own session state and matches on the nonce it minted.
response_mode is dc_api when the response is plain, and dc_api.jwt when it is encrypted to a key the verifier put in client_metadata. There is no response_uri and no redirect_uri — the answer comes back through the promise.
Unsigned requests: openid4vp-v1-unsigned
The simplest form. The whole request is the data object, and there is no client_id at all: the wallet must ignore one if it is present, because the Origin is doing that job. expected_origins is likewise meaningless here and must be ignored.
{
"response_type": "vp_token",
"response_mode": "dc_api",
"nonce": "n-4c81d0f2a7",
"dcql_query": {
"credentials": [
{
"id": "company_registration",
"format": "dc+sd-jwt",
"meta": { "vct_values": ["urn:eudi:business:company-registration:1"] },
"claims": [
{ "id": "reg_no", "path": ["registration_number"] },
{ "id": "legal_name", "path": ["legal_name"] }
]
}
]
},
"client_metadata": {
"vp_formats_supported": { "dc+sd-jwt": { "sd-jwt_alg_values": ["ES256"] } }
}
}
Note the client_metadata here carries no encryption key. response_mode is dc_api, so the response comes back in the clear; jwks and encrypted_response_enc_values_supported belong with dc_api.jwt, and sending them under dc_api tells the wallet nothing while suggesting an encryption that will not happen. The signed example below shows the encrypted pairing.
The dcql_query is the ordinary OpenID4VP query language — nothing about it changes under the DC API. See DCQL explained for what claim_sets, credential_sets and trusted_authorities add.
Signed requests: openid4vp-v1-signed
When the wallet has to authenticate the verifier through a trust framework rather than through the Web PKI the browser already checked — an EUDI relying-party registration, for example — the request is a signed Request Object, and data carries just that JWS.
{ "request": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlDT2pDQ0FlRy4uLiJdfQ..." }
The payload adds client_id — mandatory here, since it tells the wallet which Client Identifier Prefix to authenticate the signature under — and expected_origins.
{
"expected_origins": ["https://verifier.wholesale.example"],
"client_id": "x509_san_dns:verifier.wholesale.example",
"response_type": "vp_token",
"response_mode": "dc_api.jwt",
"nonce": "n-4c81d0f2a7",
"dcql_query": {
"credentials": [
{
"id": "company_registration",
"format": "dc+sd-jwt",
"meta": { "vct_values": ["urn:eudi:business:company-registration:1"] },
"claims": [
{ "id": "reg_no", "path": ["registration_number"] },
{ "id": "legal_name", "path": ["legal_name"] }
]
}
]
},
"client_metadata": {
"jwks": {
"keys": [
{ "kty": "EC", "crv": "P-256", "use": "enc", "alg": "ECDH-ES", "kid": "eph-7f3c9a", "x": "...", "y": "..." }
]
},
"encrypted_response_enc_values_supported": ["A128GCM", "A256GCM"],
"vp_formats_supported": { "dc+sd-jwt": { "sd-jwt_alg_values": ["ES256"] } }
}
}
Because this one is dc_api.jwt, client_metadata does carry a fresh ephemeral public key with use: "enc", and the wallet encrypts the whole authorization response to it.
Note what client_id does not do here. It authenticates the verifier, and it decides which trust framework the wallet checks the signature against. It is not the audience of the presentation — that is always the Origin. See Origin binding, and what it stops below.
Multi-signed requests: openid4vp-v1-multisigned
A JWS Compact Serialization carries one signature, so it can speak to one trust framework. A verifier asking for credentials that live in different trust frameworks — a national eIDAS registration and a sector scheme, say — would otherwise have to send different requests and guess which the wallet will take. The JWS JSON Serialization lets it sign the same payload once per framework.
{
"payload": "eyAiaXNzIjogImh0dHBzOi8...NzY4Mzc4MzYiIF0gfQ",
"signatures": [
{ "protected": "eyJhbGciOiAiRVMyNT..MiLCJraWQiOiAiMSJ9XX19fQ", "signature": "PFwem0Ajp2Sag...T2z784h8TQqgTR9tXcif0jw" },
{ "protected": "eyJhbGciOiAiRVMyNTY...tpZCI6ICIxIn1dfX19", "signature": "irgtXbJGwE2wN4Lc...2TvUodsE0vaC-NXpB9G39cMXZ9A" }
]
}
The split matters: client_id, verifier_info and any prefix-specific parameter such as trust_chain live in the protected header of each signature, because each one belongs to a particular identity of the verifier. Everything else — including expected_origins, nonce and dcql_query — lives once in the shared payload.
{
"alg": "ES256",
"x5c": ["MIICOjCCAeG...djzH7lA==", "MIICLTCCAdS...koAmhWVKe"],
"client_id": "x509_san_dns:verifier.wholesale.example"
}
The response
The promise resolves to a DigitalCredential. Its protocol says which of the requests was answered, and its data holds the OpenID4VP response parameters as an object.
{
"protocol": "openid4vp-v1-unsigned",
"data": {
"vp_token": {
"company_registration": [
"<issuer-signed JWT>~<disclosure: registration_number>~<disclosure: legal_name>~<key binding JWT>"
]
}
}
}
Under dc_api.jwt the same data holds a single response member carrying the JWE instead, which only the backend holding the ephemeral private key can open.
One thing that surprises people: a protocol error still resolves the promise. The wallet returns data with a single error member rather than rejecting, so a verifier that only handles the rejection path will read an error as a success.
{ "error": "invalid_request" }
That is deliberate. Under the DC API the platform may only offer wallets that can already satisfy the request, so a rejected promise would itself tell the page something about which credentials the holder holds. Keeping errors inside a fulfilled promise — and keeping them terse — narrows that leak.
ISO 18013-7 Annex C: org-iso-mdoc
The other shipped presentation protocol comes from the mobile driving licence world. It carries ISO/IEC 18013-5 structures directly, so there is no DCQL and no JSON request object: the query is a CBOR DeviceRequest.
The data object of the request has two members, both base64url-encoded CBOR without padding:
| Member | Contents |
|---|---|
deviceRequest | An ISO/IEC 18013-5 DeviceRequest, naming the doctype and the data elements wanted |
encryptionInfo | The verifier's ephemeral public key and a nonce, which the wallet encrypts the response to |
The response is not a bare DeviceResponse. It is an encrypted envelope — HPKE in single-shot mode, DHKEM(P-256) with HKDF-SHA256 and AES-128-GCM — that the verifier's backend decrypts to recover the DeviceResponse inside. The origin enters through the SessionTranscript, which is used as the HPKE info input and, in Annex C, has both engagement structures nulled out and a DC API handover in their place:
SessionTranscript = [
null, ; DeviceEngagementBytes
null, ; EReaderKeyBytes
["dcapi", dcapiInfoHash] ; Handover
]
dcapiInfo = [ Base64EncryptionInfo, origin ]
Because the origin is inside the transcript, and the transcript is inside the DeviceAuth signature, an mdoc presentation obtained on one origin will not verify against another — the same binding OpenID4VP gets from aud, expressed in CBOR.
OpenID4VP Appendix B.2.6 defines two mdoc handovers. OpenID4VPHandover (B.2.6.1) covers the client identifier, the nonce, the response encryption key thumbprint and the response URI, and is what a redirect or QR flow uses. OpenID4VPDCAPIHandover (B.2.6.2) covers the origin, the nonce and that thumbprint, and is what mso_mdoc over the DC API uses. Neither is the Annex C ["dcapi", …] handover above, which belongs to the org-iso-mdoc protocol rather than to OpenID4VP. Which one the verifier must rebuild is decided by the protocol identifier it sent.
Origin binding, and what it stops
Everything the DC API adds over a QR code reduces to one fact: the wallet learns the Origin from the user agent, not from the request.
For OpenID4VP the consequence is blunt. The audience of the presentation is always the Origin, prefixed with origin: — for example origin:https://verifier.wholesale.example — and that holds even for signed requests. The Client Identifier is never the audience under the DC API. origin is a reserved Client Identifier Prefix that a verifier must never send and a wallet must never accept in a request: it is a value only the wallet may derive, from what the browser told it.
Two distinct protections, worth keeping apart:
Request phishing. With a QR code or an openid4vp:// link, an attacker can lift a genuine, correctly signed request and put it in front of a holder from their own page or poster. The wallet sees a valid signature from a real verifier and has no way to tell that the context is wrong. Under the DC API a signed request carries expected_origins, a non-empty list the verifier signed over, and the wallet compares the browser-reported Origin against it. On a mismatch the wallet must return an error (normally invalid_request) and the holder is never asked to consent. Unsigned requests are protected less, and it is worth being precise about how much less. There is no signed identity to steal, and whatever the holder does disclose is minted for the attacker's origin, so it is worthless at the real verifier. But the wallet has no basis to refuse: it will ask the holder to consent, and an attacker who only wants the claims for themselves gets them. Origin binding stops the relay, not the ask. If a request being replayed out of context must be refused before the holder ever sees it, sign it and populate expected_origins.
Replay of the response. The holder's signature covers both the Origin and the nonce — the aud and nonce of a Key Binding JWT for dc+sd-jwt, the SessionTranscript inside DeviceAuth for mdoc. A presentation captured at one origin fails at another on the audience, and captured at the same origin later it fails on the nonce. For mso_mdoc, the dc_api.jwt response mode adds one more turn of the screw: the thumbprint of the verifier's response-encryption key goes into the OpenID4VPDCAPIHandover too, so re-encrypting a captured response to a different key is at least detectable. A dc+sd-jwt Key Binding JWT has no such field; there the encryption protects confidentiality but is not part of the binding.
What origin binding does not do is make the verifier trustworthy. It proves which origin is asking, not that the origin is entitled to ask. That is what signed requests, verifier_info and relying-party registration are for, and none of them is supplied by the browser.
Same-device and cross-device
The classic OpenID4VP flows split cleanly into two shapes, and the DC API covers both — but it moves the work from the verifier to the platform.
| Classic same-device | Classic cross-device | Digital Credentials API | |
|---|---|---|---|
| How the wallet is reached | An openid4vp:// or https:// link the OS routes to an app | The verifier renders a QR code, the holder scans it | navigator.credentials.get(), the platform shows the chooser |
| Who picks the wallet | The OS app chooser, from anything that registered the scheme | Whatever app the holder opens to scan | The platform chooser, restricted to wallets that can answer |
| Who the wallet thinks is asking | Whatever the request claims | Whatever the request claims | The Origin, as authenticated by the browser |
| Cross-device transport | n/a | The open internet, plus polling | CTAP 2.2 hybrid transport, end-to-end encrypted with a BLE proximity check |
| Response mode | fragment, or direct_post with a response_code | direct_post or direct_post.jwt to a response_uri | dc_api or dc_api.jwt, returned to the calling page |
| Where the holder ends up | Back in the browser, if the redirect works | Still on the page they started on, after polling | Still on the page they started on, in the same tab |
| What the verifier builds | Link, scheme handling, redirect handling | QR rendering, response endpoint, polling or callbacks | One API call and one backend endpoint to verify the result |
Cross-device is the part worth dwelling on, because it is easy to assume the DC API is same-device only. It is not: on desktop the browser renders the QR code, and scanning it sets up a CTAP 2.2 hybrid transport between the desktop and the phone — the same machinery passkeys use for cross-device sign-in. The BLE leg is a proximity check: the phone and the desktop have to be physically near each other, which rules out an attacker relaying a QR code to a victim on the other side of the world. The verifier's code is identical to the same-device case; it never learns which of the two happened.
The trade-off is reach. A QR code works in any browser and with any wallet that can scan it. The DC API works only where the browser implements it and allows the protocol, which is why a verifier that wants to serve everyone keeps the QR flow as a fallback behind a userAgentAllowsProtocol() check.
The whole flow at a glance
Solid arrows are requests, dashed arrows are responses.
The step that has no equivalent in a QR flow is the Origin attachment: the browser adds it, the page cannot influence it, and every signature the wallet produces covers it.
Browser and platform support
Support is real but uneven, and it splits by protocol rather than by feature. Checked on 29 September 2026 — this moves, so verify against the browsers your holders actually use before relying on it.
| Engine | Status | Protocols |
|---|---|---|
| Chrome | Shipped by default in Chrome 141, stable 30 September 2025, on Android and desktop, with cross-device over CTAP hybrid. Issuance via navigator.credentials.create() came later and started as an origin trial | OpenID4VP and org-iso-mdoc |
| Safari | Shipped in Safari 26 (September 2025) on iOS, iPadOS and macOS. Not available in WKWebView | org-iso-mdoc only — no OpenID4VP |
| Firefox | Implementation in progress; Mozilla's formal standards position on the specification has been negative | — |
| Android, as a platform | Credential Manager provides the equivalent native API, which is what the browser talks to on Android | as the browser exposes |
Two practical consequences. First, a verifier that only implements OpenID4VP over the DC API reaches Chrome and not Safari; one that only implements org-iso-mdoc reaches both but is limited to mdoc-format credentials. Second, feature detection has to be per protocol, not per API — userAgentAllowsProtocol() exists precisely because "the browser has the DC API" is not a useful answer.
Related terms
- Digital Credentials API
- OpenID4VP
- DCQL
- mdoc
- Origin
- Verifier
- Relying Party
- Key Binding
- Credential Manager
Frequently asked questions
Does the Digital Credentials API replace OpenID4VP?
No, it carries it. The DC API says nothing about what a credential request looks like or how a presentation is proved; it is the channel between a page and a wallet, and the protocol identifier says which language is spoken over that channel. openid4vp-v1-unsigned and its signed siblings are OpenID4VP 1.0 requests, with the same dcql_query, the same nonce and the same vp_token. What changes is the envelope: no request_uri to fetch, no response_uri to post to, no state, and the audience derived from the Origin rather than from the Client Identifier.
Does it replace QR codes?
Not in practice, for two reasons. Reach is the first: it works only where the browser implements it and allows the protocol you need, and Safari today accepts only org-iso-mdoc. The second is that the DC API has its own cross-device mode, in which the browser renders the QR code and runs a CTAP hybrid transport with a proximity check — so "QR code" and "DC API" are not alternatives at that level at all. A verifier serving the general public implements the DC API where it is available and keeps a classic QR plus direct_post flow as the fallback.
Why does a signed request need expected_origins when the browser already reports the Origin?
Because the two answer different questions. The Origin says where the request is running. expected_origins says where the verifier intended it to run, signed so it cannot be edited. Without it, a signed request lifted from a genuine verifier could be replayed from an attacker's page: the signature would still verify and the wallet would show the holder a real verifier's name in the wrong context. With it, the wallet compares the two and returns an error, normally invalid_request, on a mismatch, before the holder is asked anything. Unsigned requests do not need the parameter — and must ignore it — because there is no signed identity to steal in the first place.
What is the difference between openid4vp-v1-signed and openid4vp-v1-multisigned?
Only the JWS serialization, and therefore how many verifier identities the request can carry. Signed uses the JWS Compact Serialization: one signature, one client_id, one trust framework, which is what nearly every deployment needs. Multi-signed uses the JWS JSON Serialization: the same payload with a signatures array, each entry carrying its own client_id, its own verifier_info and its own prefix-specific parameters in its protected header. It exists for a verifier asking for credentials that are governed by different trust frameworks at once, so that a wallet trusting any one of them can proceed.
Can a verifier see which wallets the holder has?
No, and that is one of the reasons the API exists. The page lists the protocols it supports, the platform matches that against the wallets installed, and the holder chooses from the result. The verifier learns which protocol answered and nothing about what else was on the list, or about wallets that could not satisfy the request. This is also why a protocol error arrives as a resolved promise with a terse error value rather than as a detailed rejection — richer errors would leak what the holder does and does not hold.
Sources
- W3C Digital Credentials API
- OpenID for Verifiable Presentations 1.0, Appendix A: OpenID4VP over the Digital Credentials API
- ISO/IEC 18013-7:2025, Mobile driving licence add-on functions, Annex C
- Digital Credentials API: secure and private identity on the web
- Online identity verification with the Digital Credentials API, WebKit
- Client to Authenticator Protocol 2.2, hybrid transport
This page is informational and does not constitute legal advice. For authoritative guidance consult the OpenID Foundation and the European Commission directly.