Demonstration only — not an official ConnectID service. Test credentials, test data.
ConnectID · DC API demo

Digital Credentials API — features and limitations

Status: skeleton. Each section is filled from recorded matrix runs; claims marked [verified] carry a matrix cell behind them, claims marked [expected] await a run.

Executive summary

To be written last.

Test environment

Component Implementation Where
Issuer (OID4VCI) idp-pf-vcs services/issuer Railway
Connector AS / verifier idp-pf-vcs services/verifier (+ PingFederate for the OIDC RP flow) Railway
Demo RP surface this repo, site/ Railway
iOS wallet mydigitalid (rewrite/foundation), IdentityDocumentServices provider iPhone, iOS 26
Android wallet this repo, android/wallet (Multipaz-based) Emulator, API 35+ Play image
Browsers iOS Safari 26, Android Chrome (emulator), desktop Chrome + Safari (macOS)

Why a custom wallet, not Apple/Google Wallet: no Australian licence is in Apple Wallet or Google Wallet (verified against both vendors' jurisdiction lists, Sep 2026 — QLD's live 18013-5 mDL sits in the QLD government app; NSW is in limited early access in Service NSW). For Australian credentials, a third-party wallet is currently the only thing that can answer a DC API request — the demo's architecture is the deployable architecture, not a stand-in.

The capability matrix

Mirrors the live matrix page; snapshot inserted at write-up time.

The user-experience matrix

The capability matrix is organised by protocol and format, which is how an implementer thinks. This one is organised by what a person is actually doing — which device the credential is on, which device they are browsing on, and in what. It answers "will this work for me, right now", and it is the table to read first.

What decides the outcome, in order of how much it decides:

  1. Is the credential on the device you are browsing on? Cross-device is a different mechanism (a QR code and a Bluetooth tunnel), not a longer version of the same one, and it is the weakest link in the whole surface.
  2. Which browser engine. Not "which browser" — on iOS every browser is WebKit, so Chrome for iOS behaves as Safari does, not as Chrome does.
  3. Whether the page is top-level. An embedded or framed page is a different origin story and, in some containers, has no wallet route at all.
  4. The protocol and format — the thing the capability matrix is about, and the last thing that matters to whether a person gets through.

Same device — the credential is on the phone you are browsing on

# The person is… Result What they see
1 On an iPhone, in Safari, with the mDL in mydigitalid Works [verified on device] System sheet, consent, back to the site verified. The only fully proven browser path.
2 On an iPhone, in Chrome (or any iOS browser) [untested] Every iOS browser is WebKit and the provider is system-level, so this should behave as row 1 — but it has not been run.
3 On an Android phone, in Chrome, with the Kotlin wallet Target pass [emulator only] OpenID4VP; the one surface that also does SD-JWT. Blocked on a Play-services Chrome update.
4 On an Android phone, in Firefox No [expected] No Digital Credentials API.

Cross-device — browsing on a computer, credential on a phone

# The person is… Result What they see
5 On a desktop, credential on their iPhone Via QR only [connector route verified with a stub; device scan is the inherited OID4VP path] No DC API route at all: iOS has no cross-device responder. The QR is this person's primary route, and it is the weaker, nonce-bound one — see §15.
6 On a desktop, credential on an Android phone [untestable here] Needs a physical Android; the emulator has no Bluetooth transport for the hybrid tunnel.
7 On desktop Safari (macOS) Via QR [DC API untested; QR route verified with a stub] With no DigitalCredential global the reader skips the DC API and shows the QR at once — no wasted click. Whether Safari has a provider is still unrun.
8 In an embedded browser window (an in-app pane) Via QR [DC API failure verified; QR route verified with a stub] The DC API fails with NetworkError and no hand-off (a container limit, see the caveat). That failure is classed "no route" and the reader falls back to the QR.

The caveat on rows 7 and 8, because it is easy to over-read. The failure we actually observed was in an embedded Chromium window. Chrome's cross-device handoff is browser-process UI — the same plumbing as its passkey dialog — and embedded shells do not render it. So row 8 tells you about embedded windows and says nothing about standalone Chrome. A standalone-Chrome run is still outstanding, and the interesting question there is only whether the QR prompt appears: even if it does, rows 5 and 6 mean there is currently no phone on the far end that can answer it.

Not a browser at all

# The person is… Result What they see
9 In a native iOS app that opens a hosted page Works [verified on device] A web-auth session hosting the requester. The practical route for iOS apps.
10 In a native iOS app making the request itself Not available [verified] Apple exposes no reader-side API. App-to-app on iOS means row 9.
11 In a native Android app Expected to work [untested] Credential Manager offers a first-class reader API.
12 On a page inside a cross-origin iframe Unresolved Chrome reports the frame's origin, Safari the top-level one, and the wallet binds to whichever it saw. See §12.

The blunt summary for a product decision

Findings by dimension

1. "The DC API" is two incompatible protocols

iOS Safari speaks org-iso-mdoc (ISO 18013-7 Annex C: HPKE-encrypted CBOR, ReaderAuth-signed DeviceRequest). Android Chrome speaks openid4vp-v1-* (OID4VP Appendix A: JOSE — JWS request, JWE response). They share nothing on the wire — different query languages, different encryption, different session transcripts (dcapi vs OpenID4VPDCAPIHandover) that fail closed against each other. A verifier (or connector) MUST implement and maintain both. [expected — verify per cell]

2. Format support is asymmetric by platform

iOS third-party wallet extensions can present mdocs only (the IdentityDocumentServices provider surface has no SD-JWT scene as of iOS 26). Android Credential Manager handles both mdoc and SD-JWT VC. Anything SD-JWT-shaped excludes iPhone holders. [expected]

3. Request signing and expected_origins

Unsigned requests (openid4vp-v1-unsigned) omit client_id entirely — the wallet knows the verifier only by browser-authenticated Origin. Signed requests carry an x5c chain and MUST pin expected_origins; a request replayed from an unlisted origin is refused by the wallet. HAIP requires signed; Chrome's primary mode is unsigned. Deliberate-mismatch test: to run. [expected]

4. Response encryption

dc_api.jwt (JWE to a per-request ephemeral key) vs dc_api (cleartext). Deployment policy (settled 2026-09-03): unsigned requests permitted, responses stay encrypted — a start request asking for dc_api is refused 403 unencrypted_responses_disabled. That refusal is itself the demonstration, and a positive one: as the explainer analysis puts it, a protocol that quietly degrades when one party misconfigures itself will be misconfigured in production and nobody will notice — this one stops. [verified — policy + live 403] Whether wallets accept the unencrypted mode at all remains untested by design.

5. Invocation constraints

6. Cross-device

Desktop Chrome offers a QR/CTAP-hybrid handoff to a phone wallet. With no physical Android device this is expected untestable in this rig (emulators lack the hybrid transport) and iPhones do not answer Chrome's hybrid flow — itself a deployment-relevant finding. Desktop Safari → iPhone: to run. [expected]

7. Error surfaces

What the page receives on user-cancel vs no-matching-credential vs no-wallet-installed — and whether they are distinguishable (privacy says they largely should not be). Recorded per platform in the matrix log. [expected]

8. Transcript and holder binding differences

Annex C binds the response to sha256(cbor([EncryptionInfo-as-string, origin])); OID4VP DC API binds to sha256(cbor([origin, nonce, jwkThumbprint|null])) and makes the KB-JWT audience origin:<origin> even for signed requests. Both verified against spec bytes in the idp-pf-vcs conformance suite (G-32). [verified — conformance]

9. App-to-app

iOS exposes no native verifier-side DC API — a native verifier hosts the web requester in ASWebAuthenticationSession. Android exposes GetDigitalCredentialOption to native apps directly. [expected]

Android app-to-app, run empirically (emulator, GMS 24.23!): the loop is mechanically complete — the system picker matched the wallet's registered mDL via the registry matcher and displayed exactly the three requested claims; the wallet's consent sheet flagged the native caller as "Unknown requester" (an app, unlike a browser, brings no authenticated web origin); the response was built, JWE-decrypted by the verifier, and the mdoc's issuer chain verified. It failed at precisely one point: the OpenID4VPDCAPIHandover origin — the wallet bound the response to the calling app's identity while the verifier had pinned its web origin. The browser hides the single-origin constraint; app-to-app exposes it. A native verifier must arm the session with the app-identity origin string (apk-key-hash form) and the verifier must allowlist it. Notably all of this worked on a mid-2024 GMS — the registry/picker plumbing is older and more stable than the browser DC API surface. [verified — matrix, 2026-09-02]

9a. "No eligible IDs" on iOS — the reader leaf's AuthorityKeyIdentifier

A silent-failure class worth stating on its own, because it cost most of a day and had two distinct causes in sequence — a third recurrence should be treated as a new problem, not a regression of either.

For iOS Safari to offer a wallet document to a DC API request, the request's reader-auth leaf certificate must carry an AuthorityKeyIdentifier that iOS can match against an identifier the wallet registered for that document. Our deployed reader PKI was a pre-fix artifact whose leaf had no AKI, so every request carried nothing for iOS to match — the sheet read "Cannot Verify Identity — there are no eligible IDs available on your device", with no error anywhere in the chain. It was invisible because everything that does surface an error was fine: the credential was held, the reader root was trusted, the request was well-formed.

Correcting our own first diagnosis, because the wrong reason survives precisely when the fix works anyway: we initially attributed this to the reader root lacking a SubjectKeyIdentifier (root → nil keyIdentifier → dropped from the registered set). That inference was wrong — the wallet falls back to RFC 5280 §4.2.1.2 method (1), SHA-1 of the subjectPublicKey, when the SKI extension is absent, so the anchor was registered all along (a long-passing test proved it). The real cause is the leaf's missing AKI on the request side, not the root's missing SKI on the registration side. The reissue fixes both descriptions identically, which is exactly why the wrong one could have lived in a code comment for years.

The other, earlier cause of the same "no eligible IDs" string: the wallet only registered with iOS from a developer diagnostics screen, so a fresh install was never registered at all (fixed by registering on unlock and on save). Two real, distinct causes; one symptom.

Fix: reissue the reader PKI with SKI on the root and SKI+AKI on the leaf (both present in the current generator), set on the verifier, and re-pin the new root in the wallet's anchors. The verifying check is that the request leaf's AKI chains to the reader root's SKI. [verified — production verifier serves the fixed PKI; on-device picker retry pending]

10. Can the wallet be told who is REALLY asking? (connector-mediated flows)

The connector pattern makes the downstream RP invisible to the wallet — the consent screen shows the connector's origin. Whether the protocols can carry the downstream RP's identity distinctly:

Architecture correction (Dave, 2026-09-03): ConnectID is an OpenID Federation Trust Controller, not an exchange. The NASCAR sits with the RP; federation signing is what lets the RP authenticate to the Connector; the Connector invokes the Trust Controller to get the DCQL request signed for the requesting RP. So on Android the signed request names the RP (its federation entity id under the openid_federation prefix, chain anchored at ConnectID) while the Connector merely runs the ceremony — the RP is named to the wallet by the designed mechanism, and ConnectID signs the request without ever seeing the response. This supersedes the ad-hoc verifier_info idea above. iOS remains origin-only — the platform contrast stands. Demo milestone: Trust Controller stub + federation-signed DC API requests + wallet chain validation rendering "Wattlebrook Cellars · via ConnectID".

Demonstrated on device (2026-09-03). The stand-in Trust Controller signs the DCQL Request Object for Wattlebrook with the RP's federation key (client_id = openid_federation:<rp>, chain [RP EC → Subordinate Statement → TA EC] in the JWS trust_chain header, plus an x5c bridge because x509-first wallet stacks verify the JWS against a leaf before any trust resolution). The verifier enforces parameter integrity on the returned JAR — nonce, DCQL, origins, response mode and the whole client_metadata compared canonically, so the delegated naming authority cannot redirect or unbind the ceremony. The Android wallet walks the chain to the pinned anchor and its consent sheet renders "Wattlebrook Cellars · via ConnectID (demo Trust Controller)" — "the application requesting this data is trusted" — with the connector appearing nowhere. Caveat preserved on screen: demo wallet, cooperating by construction; a platform wallet renders none of this today. Same-run contrast: the identical ceremony under a connector-self-signed x509_hash request renders "Unknown requester". The response leg on the app-to-app surface fails on the known app-identity origin binding (§9) — the browser surface is the verifying path. [verified — matrix, screenshots]

11. Minimal disclosure is the point, and the consent screen proves it

The sharpest thing the DC API + selective disclosure buys is visible on one screen: a proof-of-age credential answers one bitage_over_18: true — and the wallet's consent sheet shows exactly that one line, no name, no date of birth, no document number. Beside it, the mDL cell asks for three attributes because a licence check genuinely needs them. Same protocol, same wallet, two consent screens: one line vs three fields, and the difference is the whole argument for the technology.

The failure mode worth naming, because it's invisible on the wire and easy to reach for: a verifier CAN request age_over_18 and given_name/family_name from the same credential (the catalogue declares them), and it would build a valid request and a full response. But a verifier that asks for the bit and the name has re-identified the holder while keeping the branding of not having done so — the consent screen would then show three lines and the privacy claim would be theatre. The demo deliberately requests only the boolean (REQUESTED_CLAIMS['age-over-18-sd-jwt'] = ['age_over_18']). Minimal disclosure is a choice the verifier makes on every request, not a property the credential enforces — which is exactly why it belongs on the consent screen where the holder can see it.

(Platform note: this credential is SD-JWT, so per finding #2 it is presentable via the DC API on Android but not iOS — the iOS provider surface is mdoc-only. The equivalent iOS demonstration needs the age-over-18-mdoc variant.) [verified — verifier requests only the boolean; Android presentation in progress]

12. The document TYPE is gated too, and it is orthogonal to the protocol

A document type outside an application's granted list is unreachable on iPhone whatever the wire format. Choosing ISO or OpenID makes no difference. Neither does the credential format. The gate is elsewhere and no protocol decision gets past it.

The mechanism is an Apple entitlement (com.apple.developer.identity-document-services.document-provider.mobile-document-types), granted per App ID, enumerating the document types that application may provide. It is not a capability the implementer configures. The wallet in this demo is granted exactly three: org.iso.18013.5.1.mDL, org.iso.23220.photoid.1, org.iso.23220.1.jp.mnc.

We hit this with a real credential. An Australian proof-of-age type (au.com.idpartners.proofofage.1) was defined, issued, and successfully loaded into the wallet. It can never appear in the picker: the wallet filters held credentials against the entitled set before registering them with the operating system, so the platform is never told the credential exists. There is no error to diagnose — the holder has a valid credential the phone behaves as though it does not have. [verified]

Not established: whether Apple would grant a custom national type on request. Nobody tested that. What is established is narrower and still consequential — it is not there by default, and obtaining it is the platform vendor's decision for that specific application.

Why this matters more than the format restriction. The format restriction constrains which technology a roadmap can use. This one constrains which credentials a country can define. Any nationally-defined credential that is not already an international standard type — a concession card, a working-with-children check, a trade licence — is unreachable on iPhone until the vendor grants that type to the wallet carrying it.

The workaround, and its price. Do not define a national type: carry the national data inside an internationally-defined one already on the list. That is why org.iso.23220.photoid.1 matters. The costs are real — you inherit someone else's data model, it does not generalise to credentials with no international equivalent, and it concedes that the vendor's list is the de facto standard.

13. What a relying party actually integrates is OpenID Connect

The demo's relying party performs no wallet work at all. It runs a FAPI-shaped authorization code flow against the connector — pushed authorization request, redirect, code exchange, ID token — and receives claims in a JWT it verifies against a published JWKS. It holds no reader keys, speaks no OID4VP, and never learns a Digital Credentials API was involved.

Everything platform-specific sits behind that boundary, including the decision this document spends most of its length on: which protocol to use is chosen by the connector, on the device, from the user agent — Annex C for Apple, OpenID4VP elsewhere. The relying party asked for a verified person, not for a protocol.

The practical consequence for adoption is the whole point: every limitation in this document is one integrator's problem, not every integrator's. A merchant adopting this writes the OIDC client it has written before. [verified — running in the demo journey]

Implemented and tested to refuse: PAR required, PKCE S256 required, private_key_jwt with the client key fetched from its own jwks_uri, the iss response parameter, exact redirect-URI matching, single-use request URIs and codes. Not implemented: sender-constrained tokens (mTLS or DPoP), which FAPI 2.0 requires — a browser demo cannot meaningfully show mutual TLS, and it is the ID token rather than the access token that carries the result here.

14. The user gesture is spent by an await

navigator.credentials.get() requires transient activation, and Safari does not keep that activation across an await. A page that arms its verifier session inside the click handler and then calls get() spends the gesture on the network round trip and fails with:

NotAllowedError: Calling get() needs to be triggered by an activation triggering user event.

The fix is structural, not a tweak: fetch the request on page load and have the click handler call get() synchronously with nothing awaited in front of it. The cost is a session armed for a person who may never press the button.

This is worth stating because it is timing-dependent — it survives testing on a fast connection and fails on a real phone on mobile data, which is exactly the population a demo is shown to. It also shapes the UI: a wallet request cannot be preceded by any interstitial that fetches something, so "confirm, then we'll prepare your request" is not implementable. [verified on device]

15. There are two routes to a wallet, and they are not the same evidence

Everything above this point assumes the Digital Credentials API. It is not always there, and where it is, it does not always have a wallet behind it. A production connector needs a second route, and the second route is not merely a fallback — on a desktop it is the only route to an iPhone.

The two routes.

Digital Credentials API OpenID4VP over QR / deep link
Where it works Same device, browser brokers to an OS-registered wallet Anywhere a wallet can scan a QR or open openid4vp://
What the response is bound to The requesting origin, via the session transcript (the wallet reconstructs ReaderAuth from the origin the browser reported) The request nonce and response_uri only
What the wallet knows about the site The origin, from the browser Nothing — it never learns which website the holder was looking at
Relay resistance Yes: a response cannot be replayed from a site the holder was not on No: this is the relay class the DC API was designed to close

The connector now carries the route into the ID token as amr["wallet","dc_api"] or ["wallet","oid4vp_qr"] — so a relying party can distinguish origin-bound evidence from nonce-bound evidence. Hiding the difference would be deciding on the RP's behalf. [verified — the RP renders amr: ["wallet","oid4vp_qr"] after a QR-route presentation]

Detection: what is knowable before the click, and what is not.

Failure classes, and what each means for routing. NotAllowedError with "activation" in the message is a spent gesture — re-arm and let them retry; it is not a routing failure. AbortError or a cancel is the holder declining — re-arm and offer the QR, because a cancel and a no-matching-credential are indistinguishable to the page (§7) and the holder may simply hold the card in another wallet. TypeError "must present", NotSupportedError, NetworkError and anything unrecognised mean the DC API has no route here — fall back. A verification failure is never a routing failure. [verified — all five classes tested]

The desktop inversion. iOS has no cross-device DC API responder (§6). So a person at a computer with the credential on their iPhone has no DC API route whatsoever, and the QR is not their fallback but their primary — and the weaker binding is the one that population always gets. Any assurance policy has to be written knowing that.

A limit of our own, recorded rather than hidden. The verifier's QR path takes a coarse pid | mdl | both, not a catalogue id, so today only the mDL and PID have a QR route; the EU age card does not. The reader says so instead of requesting the wrong credential. Making the QR path catalogue-driven is the obvious next change and would let the same request go down either route.

What is and is not proven. The whole second route — QR rendered, wallet answers the verifier directly, page polls, connector reads claims by session, ID token minted with the route in amr, RP renders the claims — is verified end to end against a stub verifier that reports pending twice before verified, so the polling was exercised rather than short-circuited. The wallet-scans-the-QR leg on a real phone is the pre-existing OpenID4VP path this project inherited and verified before any DC API work began. The two have not yet been run together on a device in one sitting; that is the next thing to do, and it is one scan.

What review caught

Evidence the demo is not a happy-path toy: the guardian review of the verifier's federation mode proved a real gap before landing it — the parameter-integrity check compared the response-encryption key's kid label, which a hostile Trust Controller could keep while substituting the key material or downgrading the advertised algorithms. The check was replaced with a canonical whole-client_metadata comparison (keys sorted recursively, array order preserved, so a TC that merely re-serialises is not read as tampering). The transcript's thumbprint binding would still have failed the ceremony closed — but late and opaquely; the review moved the refusal to where it names itself.

Spec ambiguities and gaps observed

Collected during implementation; see also idp-pf-vcs conformance/GAP-REGISTER.md G-32.

Recommendations

To be written last.