Engineering · 2026-06-26
OpenID4VP over the Digital Credentials API: Browser-Native Wallet Selection in Chrome and Safari
By J. W. Bouckaert
For a decade, online identity verification has meant one of three things: the relying party operated its own wallet, the relying party integrated one wallet SDK per ecosystem, or the user got punted into a redirect chain that opened, in sequence, every wallet app on their device until one of them claimed the URL. None of these scale. None of them are private. None of them are what a browser is for.
That changed in production this year. The W3C Digital Credentials API shipped enabled-by-default in Chrome 141 on October 3, 2025, and Safari turned it on across Safari 26 on macOS, iOS, and iPadOS under the WebKit Identity Document Services framework. The browser, not the relying party, now mediates which wallet answers an OpenID4VP request. The relying party never enumerates installed wallets, never holds the consent surface, and never sees anything the user did not explicitly disclose.
This article specifies how the new conveyance layer composes with OpenID for Verifiable Presentations 1.0—finalized 9 July 2025—what the browser actually owns versus what stays the verifier's responsibility, the unsolicited-presentation threat model the W3C is building against, and the concrete relying-party shape that lets a single integration accept mdoc driver's licenses, SD-JWT verifiable credentials, and W3C VC-JWT from any conforming wallet on any conforming browser.
The conveyance problem the DC API solves
OpenID4VP is a protocol. Before the Digital Credentials API, three transports existed in practice, each with a structural defect:
| Transport | Mechanism | Defect |
|---|---|---|
| Cross-device QR | RP renders a QR; mobile wallet scans it; response returns over response_uri | Works, but requires two devices and adds 5 to 15 seconds of UX latency |
Custom URL scheme (openid4vp://) | Same-device deep link claimed by whichever wallet registered the scheme | Wallet collisions; OS prompts; silent enumeration of installed wallets |
| Vendor SDK | Direct JS or native SDK from a specific wallet vendor | RP locked to that wallet; interoperability lost; ships third-party code into the auth path |
The DC API replaces all three for the same-device, in-browser case. The relying party invokes navigator.credentials.get({ digital: { requests: [...] } }). The browser inspects the request, finds wallets installed on the device or available through OS-level credential providers, presents only the ones that can satisfy the query, and routes the chosen wallet's response back to the page as a DigitalCredential object.
The relying party learns nothing about which wallets the user has, what the picker looked like, or which wallets declined. The only output is the credential the user chose to release, in the format the wallet returned it.
The browser's responsibilities
The DC API draws a hard line between platform responsibility and verifier responsibility. The browser owns:
- Wallet discovery. OS-registered credential providers on iOS 26 and Android 15+, plus browser-installed wallet PWAs and extensions, are enumerated by the user agent. The site never sees the list.
- Matching. The browser inspects each request's protocol envelope and credential query, asks each candidate wallet whether it has a matching credential, and only surfaces wallets that say yes. Negative answers are not exposed to the RP.
- The picker UI. The OS or browser draws the chooser, including wallet branding. The site cannot style, intercept, or pre-select.
- User consent. The wallet draws the disclosure sheet (which claims, to which RP origin, for which purpose). The site cannot dismiss or skip it.
- Transport security. The browser carries the request and response between site and wallet over a channel the page cannot eavesdrop on.
What the browser deliberately does not do:
- Verify the response. The
vp_tokenarrives unvalidated. The relying party must verify the issuer signature, the holder binding, the nonce, and the audience. - Authorize the request. A page can call the API with no user-initiated reason. The browser shows the prompt; the user has to refuse.
- Cache credentials. Each call is a fresh disclosure. There is no
navigator.credentials.preventSilentAccess()analogue that persists wallet state across calls.
This division is the right one. The browser is the only party in the loop that can see every wallet on the device without leaking that view to the page; the verifier is the only party that can decide whether a particular presentation satisfies a particular policy. Centralising both in either place would break the model.
OpenID4VP over the DC API
OpenID4VP 1.0 added the OpenID4VP over the W3C Digital Credentials API profile specifically for this conveyance. The on-the-wire shape inside navigator.credentials.get({ digital }) is, end-to-end:
┌────────────────────────────────────────────┐
RP page ──►│ navigator.credentials.get({ digital: { │
│ requests: [{ │
│ protocol: "openid4vp-v1-signed", │
│ data: { │
│ client_id: "https://verifier.example",│
│ nonce: "<server-issued, single-use>", │
│ dcql_query: { credentials: [ ... ] } │
│ } │
│ }] │
│ }}) │
└──────────────────┬─────────────────────────┘
▼
┌─────────────────┐
│ User agent │ discovery, matching, picker, consent
└────────┬────────┘
▼
┌─────────────────┐
│ Selected wallet │ builds vp_token bound to nonce + client_id
└────────┬────────┘
▼
┌────────────────────────────────────────────┐
│ DigitalCredential.data = { │
│ vp_token: { ... } │
│ } → verify on the server │
└────────────────────────────────────────────┘
Two query languages are in scope. DCQL—Digital Credentials Query Language—is the OpenID4VP 1.0 native form and is the one to ship against in 2026. Presentation Exchange 2.0 remains supported for wallets that have not yet upgraded; PasskeyBridge's verifier accepts both and normalises them server-side.
The credential formats the browser is format-agnostic about—and that PasskeyBridge's shield-vc-present verifier handles natively—are:
| Format | Issuer signature | Holder binding | Where it dominates |
|---|---|---|---|
| ISO/IEC 18013-5 mdoc | COSE_Sign1 over MSO | Device key in MSO | Mobile driver's licenses (US, EU, AU) |
| IETF SD-JWT VC (draft-ietf-oauth-sd-jwt-vc) | JWS over SD-JWT | cnf claim with holder JWK | EUDI Wallet, enterprise issuers |
| W3C VC-JWT (VC Data Model 2.0) | JWS over VC | cnf or proof property | Long tail of W3C-VC issuers |
A wallet-agnostic relying party accepts whichever the wallet returns and runs format-specific verification on the server. Hard-coding to one is the architectural mistake that locks the integration to a single wallet ecosystem.
The unsolicited-presentation threat model
The DC API is reachable from any HTTPS origin. That is the protocol's strength and its central threat surface. The W3C Threat Model for Decentralized Credentials catalogs the relevant adversaries; the one that matters most for relying parties is the unsolicited presentation request.
Shape of the attack:
- A malicious page (phishing kit, ad iframe in a permissive context, compromised script in a third-party dependency) calls
navigator.credentials.get({ digital })with a query for a high-value credential—mdoc driver's license, age-over-21, national ID. - The browser shows the user-agent picker and the wallet's consent sheet.
- A habituated user taps through, expecting it to be the site they meant to verify on.
- The attacker receives a valid, freshly-signed presentation they did not earn.
Three structural defenses live in different parts of the stack:
Browser-side—Chrome and Safari gate the call behind user activation, render an origin-clear picker, and the wallet renders a strong consent sheet that names the requesting origin and the claims being asked for. Neither browser allows silent invocation.
Wallet-side—Conforming wallets show the RP's client_id (an HTTPS origin in the unsigned variant, or a verified identifier in the signed variant) and the exact claim set being released. The OpenID4VP client_id_scheme = web-origin variant binds the response to the origin the browser observed, so a phished origin cannot pass the binding check on a verifier that enforces it.
Verifier-side—The relying party's verifier must reject any vp_token whose audience does not exactly match the verifier's expected client_id, whose nonce does not match the one the verifier issued less than 60 seconds prior, or whose holder binding does not chain to a key the wallet asserts owns the credential. Skipping any of these turns a per-page disclosure into a replayable token.
PasskeyBridge's shield-vc-present edge function enforces all three checks before returning an attestation, and emits a zero-PII verification event regardless of which format the wallet returned. The verification primitives are the same ones we documented in Selective Disclosure with SD-JWT and Verifiable Credentials Without Blockchain; the DC API just changes how the request reaches the wallet.
Adoption posture: What ships where, today
| Surface | DC API state | Notes |
|---|---|---|
| Chrome 141+ desktop & Android | Stable, on by default | Shipped 3 Oct 2025 |
| Safari 26+ macOS & iOS 26+ | Stable | WebKit: Identity Verification with DC API |
| iOS 26 third-party browsers (Chrome iOS, Edge iOS, Firefox iOS) | Available via iOS Identity Document Services | Per Chrome blog announcement |
| Firefox desktop | Not yet shipped | Tracking in Bugzilla; no stable date |
| Apple Wallet mdoc driver's licenses | Available in supporting US states + EU national rollouts | First-party issuer for iOS |
| Google Wallet | Android credential provider role | Wallet ecosystem dependent on state/country issuer |
| EUDI Wallet (reference) | DC API conveyance in scope for ARF | EUDI Wallet ARF |
The cross-section of users who can present a real mdoc credential to an in-browser verifier through the DC API in 2026 is still narrower than the cross-section who can use a passkey. But it is the only architecture in the W3C and OpenID rec stack where the same code path serves a US state mobile driver's license, a German national ID, and an enterprise-issued SD-JWT employment credential without the relying party committing to one wallet vendor.
The wallet-agnostic relying-party pattern
The integration shape that survives wallet churn looks like this:
┌─────────────────────────┐
Browser ──────►│ RP page │
│ • mints server nonce │
│ • assembles DCQL │
│ • calls DC API │
└────────────┬────────────┘
▼
┌─────────────────────────┐
│ RP verifier (server) │
│ • format-detect │ mdoc | SD-JWT VC | VC-JWT
│ • verify issuer sig │ cose | jws | jws
│ • verify holder bind │ device key | cnf | cnf
│ • check audience+nonce │
│ • emit zero-PII attest │
└─────────────────────────┘
The three behaviours that make this RP wallet-agnostic, in order of how often we see them missed in customer audits:
- Mint the nonce server-side, every time. A nonce generated in JS, in the browser, lasts exactly as long as the attacker's XSS. The verifier issues a 128-bit single-use nonce, returns it to the page over an authenticated channel, and rejects any
vp_tokenwhose nonce it did not just issue.
- Accept every format the wallet might return. Hard-coding to mdoc shuts out every SD-JWT issuer and every W3C VC-JWT credential. The format is in the
vp_token's outer shape; detect it and dispatch. The PasskeyBridgeshield-vc-presentendpoint normalizes all three into the same canonical attestation envelope.
- Bind the audience to the exact
client_idthe verifier expects. Wallets that implementclient_id_scheme = web-originwill refuse to release credentials to a mismatched origin. Verifiers that do not enforce the audience check on the way back have no way to catch the case where the wallet did not enforce it on the way out.
This is the spine of an integration that survives the next wallet vendor change. The DC API is the conveyance; OpenID4VP is the protocol; PasskeyBridge handles the verification.
Consequences for the identity stack
The browser-mediated layer changes three things about the identity-verification market in the next 18 months.
The wallet-lock-in window is closing. A relying party that picks a single wallet SDK in 2026 is committing to a 12-month integration debt against a layer the browsers now own. The cost of swapping wallets falls to zero on the RP side, because the RP no longer integrates against any one of them. The competition moves to wallets themselves—which ones users install, which ones their states and employers issue.
The redirect-only architectures look obsolete. Same-device redirect flows survive on iOS Safari pre-26 and on Firefox desktop, but the architectural pull is toward DC API everywhere a browser supports it. Verifiers that ship only the redirect path in 2026 will be asked, in 2027, why the wallet picker bounces the user out of the page.
The verifier becomes the differentiator. When every wallet is reachable through the same API, the only place identity-trust vendors compete is on the verifier—format coverage, latency, zero-PII posture, freshness, compliance evidence. PasskeyBridge sits on this side of the line by design.
The W3C draft is still moving (the current snapshot is the 16 June 2026 Working Draft). The conveyance is shipping. The protocol is final. The verifier is where the engineering attention belongs.
---
Further reading:
- W3C Digital Credentials (Working Draft)
- Chrome for Developers: Digital Credentials API shipped (3 Oct 2025)
- WebKit: Online Identity Verification with the Digital Credentials API
- WebKit Features in Safari 18.4 (underlying Identity Document Services framework)
- OpenID for Verifiable Presentations 1.0 (Final, 9 Jul 2025)
- W3C Threat Model for Decentralized Credentials
- IETF draft-ietf-oauth-sd-jwt-vc
- EUDI Wallet Architecture and Reference Framework
- PasskeyBridge: Verifiable Credentials Without Blockchain
- PasskeyBridge: Selective Disclosure with SD-JWT
- PasskeyBridge: EU Digital Identity Wallet + Carrier Signals