Issuance with OpenID4VCI
We’ve implemented issuance with OpenID4VCI 1.0 following the High Assurance Interoperability Profile (HAIP), with attestation preview as a custom addition. OpenID4VCI defines two grant flows and we use both across our two issuance implementations.
We currently have two issuance implementations: the pid_issuer, a specialized
issuer specifically for Dutch PIDs, which this page is about, and
issuance_server, a disclosure-based-issuance service that can issue all kinds
of things (which you can read about here). The pid_issuer uses the
authorization code flow described below; the issuance_server uses the
pre-authorized code flow.
PID issuance
The NL Wallet requires at least the SD JWT format for PID attestations. The MSO
mDoc can be issued as well. The attestation type and paths to the login claim
and the recovery_code are dynamically configured. See the pid_attributes
field in the wallet-config.json for details.
In the pid_issuer, the Authorization Server and Credential Issuer roles are
combined: the wallet talks to a single PID Issuer, which hosts its own /par,
/authorize, /token, /nonce and /credential endpoints. Behind the scenes,
the PID Issuer delegates the actual user authentication to an upstream OpenID
Connect provider — in practice RDO Max acting as a DigiD broker.
For the internal structure of the PID Issuer, see
PID Issuer architecture.
PID issuance OAuth flow overview
The PID Issuer runs two independent OAuth 2.0 authorization-code exchanges
that share no parameters:
Wallet ↔ PID Issuer — the HAIP exchange described above. The wallet pushes a PAR, calls
PID Issuer’s/authorize, eventually receives an authorization code at its ownredirect_uri, and exchanges it at thePID Issuer’s/token. The wallet’sclient_id,redirect_uri,state, PKCE pair, WIA and DPoP all terminate at thePID Issuer.PID Issuer ↔ RDO Max — a second, freshly minted exchange the
PID Issuerstarts while handling the wallet’s/authorize. Every parameter here is thePID Issuer’s own: its DigiDclient_id, its ownredirect_uri(thePID Issuer’s/digid/callback), a randomstate(theissuer_state), its own PKCE pair,scope=openidand a fresh OIDCnonce. RDO Max redirects back to thePID Issuer’s callback — never to the wallet.
The PID Issuer terminates the upstream round-trip at its own callback,
generates its own authorization code, and only then redirects the browser
back to the wallet. RDO Max never redirects the browser to the wallet directly.
The two exchanges are linked solely by a server-side state-bridge entry,
keyed by the issuer_state, that remembers the wallet’s original
redirect_uri, state and PKCE challenge (and the upstream PKCE verifier)
while the user is away at DigiD.
Because the upstream round-trip is terminated at the PID Issuer, the upstream
/token + /userinfo exchange (which yields the BSN) and the BRP attribute
lookup happen in the /digid/callback handler, before the wallet ever calls
/token. By the time the wallet exchanges its code at /token, the attributes
are already determined and stored in the issuance session, so /token only
verifies the wallet’s PKCE code_verifier and WIA, and issues the access
token — there is no upstream interaction at /token. If anything fails in
the callback (BSN, BRP, document build), the PID Issuer redirects the
browser back to the wallet’s redirect_uri with an OAuth error response.
Parameter handling, wallet ↔ PID Issuer (everything terminates at the PID Issuer):
Parameter |
Handling |
|---|---|
|
validated against accepted wallet client ids; never forwarded |
|
wallet’s universal link; the PID Issuer redirects here with its own code |
|
remembered in the state bridge, echoed on the final wallet redirect |
|
wallet’s PKCE ( |
|
the requested PID credential configuration |
WIA headers |
wallet <-> PID Issuer only |
|
wallet <-> PID Issuer only |
Parameter handling, PID Issuer → RDO Max (all freshly generated by the PID
Issuer, on both upstream /authorize and /token):
Parameter |
Value |
|---|---|
|
the PID Issuer’s DigiD client id |
|
the PID Issuer’s own |
|
random |
|
the PID Issuer’s own PKCE pair ( |
|
|
|
fresh random, required by nl-rdo-max |
|
the PID Issuer’s own verifier ( |
To keep the diagram focused on the OpenID4VCI / RDO Max delegation, the
wallet-side DPoP layer and the Wallet Instance Attestation (WIA) are omitted
below — both sit strictly between the wallet and the PID Issuer and do not
affect the delegation to RDO Max.
PKCE tracer values: the wallet uses (v1, c1) toward the PID Issuer; the
PID Issuer uses (v2, c2) toward RDO Max. Each verifier is checked only by
the party that issued the challenge.
sequenceDiagram
autonumber
participant OS
participant Wallet
participant PI as PID Issuer
participant RDO as RDO Max
%% ---- Optional issuer-initiated Credential Offer ----
Note over OS,RDO: Credential Offer (optional, issuer-initiated)
PI-->>OS: openid-credential-offer://?credential_offer_uri=<url><br/>(e.g. via QR code or same-device link)
OS->>Wallet: open with offer URL
Wallet->>PI: GET <credential_offer_uri>
PI->>Wallet: { credential_issuer, credential_configuration_ids:<br/> ["eu.europa.ec.eudi.pid_vc_sd_jwt"],<br/> grants: { authorization_code: { issuer_state: "s_iss" } } }
%% ---- Metadata discovery ----
Wallet-->>PI: GET /.well-known/openid-credential-issuer
PI-->>Wallet: { credential_issuer, credential_configurations_supported,<br/> authorization_servers, nonce_endpoint,<br/> credential_endpoint, ... }
Wallet->>Wallet: pick AS from authorization_servers[]
Note over OS,RDO: Authentication phase
Wallet-->>PI: GET /.well-known/oauth-authorization-server
PI-->>Wallet: { pushed_authorization_request_endpoint,<br/> authorization_endpoint, token_endpoint,<br/> require_pushed_authorization_requests: true }
Wallet->>Wallet: generate PKCE_W (v1, c1 = S256(v1)),<br/>state=s1
%% ---- PAR ----
Wallet->>PI: POST /par
note right of Wallet: response_type=code<br/>client_id=nl-wallet-app<br/>redirect_uri=<wallet_ul><br/>code_challenge=c1, code_challenge_method=S256<br/>state=s1<br/>scope=eu.europa.ec.eudi.pid_vc_sd_jwt<br/>issuer_state=s_iss ← if from offer
PI->>PI: store PAR keyed by request_uri
PI->>Wallet: 201 { request_uri: "urn:...abc", expires_in: 60 }
%% ---- Front-channel authorization ----
Wallet->>OS: open browser → GET <PID_Issuer>/authorize?<br/>client_id=nl-wallet-app&request_uri=urn:...abc
OS->>PI: GET /authorize?client_id=nl-wallet-app&request_uri=urn:...abc
PI->>PI: consume PAR, validate client_id
PI-->>RDO: GET /.well-known/openid-configuration<br/>(first upstream use, cached thereafter)
RDO-->>PI: upstream OIDC metadata<br/>(authorization_endpoint, token_endpoint, userinfo_endpoint)
PI->>PI: generate PKCE_P (v2, c2) and random issuer_state=s2,<br/>store state_bridge[s2] = {<br/> wallet_redirect_uri=<wallet_ul>, wallet_state=s1,<br/> wallet_code_challenge=c1, upstream_code_verifier=v2 }
PI->>OS: 302 Location: <RDO>/authorize?<br/>response_type=code&client_id=pid-issuer-digid&<br/>redirect_uri=<PI>/digid/callback&<br/>code_challenge=c2&code_challenge_method=S256&<br/>state=s2&scope=openid&nonce=<random>
OS->>RDO: GET /authorize?...
note over OS,RDO: user authenticates via DigiD
RDO->>OS: 302 Location: <PI>/digid/callback?<br/>code=up_code&state=s2
OS->>PI: GET /digid/callback?code=up_code&state=s2
%% ---- Upstream token + userinfo, BRP, mint issuer code ----
PI->>PI: consume state_bridge[s2] → { wallet_*, v2 }
PI->>RDO: POST /token<br/>(grant_type=authorization_code, code=up_code,<br/>redirect_uri=<PI>/digid/callback, code_verifier=v2,<br/>client_id=pid-issuer-digid, upstream client auth)
RDO->>PI: { access_token: <upstream_at>, ... }
PI->>RDO: GET /userinfo (Authorization: Bearer <upstream_at>)
RDO->>PI: BSN (encrypted JWE)
PI->>PI: Lookup attributes in BRP,<br/>build PID documents (SD-JWT + mdoc),<br/>mint authorization code=code1,<br/>store AuthCodeIssued session[code1] = {<br/> documents, wallet_code_challenge=c1 }
PI->>OS: 302 Location: <wallet_ul>?code=code1&state=s1
OS->>Wallet: openWallet(code=code1, state=s1)
Wallet->>Wallet: verify state == s1
%% ---- Token exchange ----
Wallet->>PI: POST /token
note right of Wallet: grant_type=authorization_code<br/>code=code1<br/>redirect_uri=<wallet_ul><br/>code_verifier=v1 ← wallet's PKCE<br/>client_id=nl-wallet-app
PI->>PI: load session[code1],<br/>verify S256(v1) == c1 (wallet PKCE),<br/>no upstream interaction
PI->>Wallet: { access_token: <at>, token_type: "Bearer",<br/> expires_in: 3600,<br/> authorization_details: [{..., credential_identifiers:[...]}] }
Note over OS,RDO: Issuance phase
Wallet->>PI: POST /previews<br/>Authorization: Bearer <at>
PI->>Wallet: previews
loop for every credential
Wallet->>PI: POST /nonce
PI->>Wallet: { c_nonce: "n1" }
Wallet-->>PI: GET /.well-known/openid-credential-issuer<br/>(usually cached)
PI-->>Wallet: issuer metadata
Wallet->>PI: POST /credential<br/>Authorization: Bearer <at>
note right of Wallet: credential_configuration_id:<br/> "eu.europa.ec.eudi.pid_vc_sd_jwt"<br/>proofs: { jwt: [<PoP_1>, <PoP_2>, ...] }
PI->>Wallet: { credentials: [{ credential: "<sd-jwt-vc>" }, ...] }
end
Timeouts and expiry
Issuance spans several hops — an out-of-band offer, one or two OAuth round-trips, and a multi-step issuance phase — and each hop is bounded by its own, independent clock. Where a timeout becomes visible, and whether it can be surfaced to the wallet at all, depends on which clock expired and which flow is in play.
The clocks
Clock |
Flow |
Default |
Where set |
Bounds |
|---|---|---|---|---|
PAR entry TTL |
auth-code |
60 s |
|
time between |
State-bridge entry TTL |
auth-code |
30 min |
|
the whole user-agent round-trip through the upstream IdP (or consent page) |
State-bridge delete leeway |
auth-code |
24 h |
|
extra grace after the TTL in which an expired entry stays recoverable |
Issuance session inactivity |
both |
30 min |
|
inactivity between |
Upstream IdP session |
auth-code (PID) |
external |
nl-rdo-max |
how long DigiD keeps the login state while the user authenticates |
The issuance session also has deletion timeouts (successful_deletion, 5 min;
failed_deletion, 4 h) that govern cleanup after the session is done or
expired, mirroring the state bridge’s split between expiration and deletion.
Where an expiry surfaces
The decisive question is whether the expiry is hit on a wallet→issuer API
call (which can carry a structured error back to the wallet) or during a
front-channel browser redirect (where the only way back to the wallet is the
redirect_uri, which may no longer be recoverable).
Failure mode |
Hit on |
Wire signal |
Wallet result |
|---|---|---|---|
Pre-authorized code reused / session gone |
|
|
“QR code no longer valid” screen |
Issuance session expired mid-issuance |
|
|
“session expired” screen |
State-bridge entry expired, within leeway |
browser |
OAuth |
redirect-uri error |
State-bridge entry deleted (after leeway) |
browser |
plain-text 400 |
dead-end — nothing reaches wallet |
Upstream IdP (DigiD) abandoned / timed out |
browser |
none (callback never runs) |
dead-end — nothing reaches wallet |
Pre-authorized code, reused or expired (
issuance_server). The wallet POSTs the code to/tokenbut the session is gone or alreadyDone. This maps to the spec’sinvalid_grant; the wallet recognises a pre-authorizedinvalid_grantand shows a “QR code no longer valid” screen. It is a plain API call, so the error reaches the wallet cleanly.Issuance session expired mid-issuance (both flows). The holder dwells on a preview or the PIN screen past the inactivity timeout and the session is cleaned up. The next
/credentialcall presents an access token whose session no longer exists, which maps toinvalid_token; the wallet shows a “session expired” screen. Also an API call, so it surfaces cleanly.Auth-code browser dead-end (both
pid_issuerandacf_demo_issuer). During the front-channel round-trip — DigiD for the PID issuer, the local consent page for the acf-demo issuer — the state-bridge entry expires. The callback then arrives carrying only the opaquestate(the bridge key); the wallet’sredirect_urilived solely inside that entry. Two sub-cases:Within the delete leeway, the entry is expired-but-still-present, so the callback recovers the
redirect_uriand redirects the browser back to the wallet with an OAuth error (invalid_request). The wallet surfaces this as a redirect-uri error rather than a dead-end. This is exactly what the leeway buys.After the leeway, the entry has been deleted, nothing links back to the wallet, and the callback dead-ends as a plain-text
400in the browser.
Upstream/DigiD timeout (PID only). If the user abandons or times out at DigiD, RDO Max never redirects back to the issuer, so our
/digid/callbacknever runs and nothing server-side can observe it. The browser dead-ends at RDO Max. Only wallet-side detection of the abandoned step-out can catch this.
Relating the clocks
The state-bridge TTL must be at least the upstream IdP session TTL. Otherwise a login the user completed in time upstream could still find its bridge entry expired on the way back, turning a success into the dead-end above. The current 30 min is a placeholder; it should track the real DigiD session lifetime.
The delete leeway is deliberately far longer than the TTL (24 h vs 30 min), so a user who steps away mid-flow and returns much later still gets a graceful error redirect instead of a dead-end. The bridge entry holds no personal data (only the wallet’s
redirect_uri,stateand PKCE challenge), so retaining it that long is cheap.
The code-level mechanics of the state-bridge dead-end and its leeway live in the
PID Issuer — see
PID Issuer architecture.
Key generation and usage during issuance
Wallet App
The wallet uses the Wallet Backend to generate attestation private keys and sign
the issuer’s nonce with them. It does this by sending a PerformIssuance
instruction, which returns one Proof of
Possession (PoP) per generated key by signing the c_nonce from the issuer.
For PID issuance, a WIA is additionally required. The wallet sends an
IssueWia instruction, which returns a
fresh WIA (signed with the WB’s certificate chain) together with a disclosure
PoP that binds the WIA to the current request.
The following sequence diagram depicts how this happens.
sequenceDiagram
%% Force ordering by explicitly setting up participants
participant wallet as Wallet Core (App)
participant wallet_provider as Wallet Backend
participant hsm as WB HSM
participant db as WB Database
wallet->>+wallet_provider: instruction: perform_issuance(c_nonce, key_count)
wallet_provider ->>+ hsm: generateECDSAPrivateKeys(key_count)
hsm ->> hsm: generate ECDSA private keys<br/>encrypt each private key with attestationWrappingKey
hsm -->>- wallet_provider: encryptedECDSAPrivateKeys, ECDSAPublicKeys
wallet_provider ->>+ db : storeAttestationKeys(encryptedAttestationPrivateKeys, attestationPublicKeys)
db -->>- wallet_provider: OK
loop for every encryptedAttestationPrivateKey
wallet_provider ->>+ hsm: sign(encryptedAttestationPrivateKey, c_nonce)
hsm ->> hsm: Decrypt encryptedAttestationPrivateKey with attestationWrappingKey<br/>sign c_nonce with decrypted key
hsm -->>- wallet_provider: PoP
end
wallet_provider-->>-wallet: instruction response: keyIdentifiers, PoPs
opt WIA required (PID issuance)
wallet->>+wallet_provider: instruction: issue_wia(nonce, aud)
wallet_provider ->>+ hsm: generateECDSAPrivateKey()
hsm ->> hsm: generate WIA signing key<br/>encrypt with attestationWrappingKey
hsm -->>- wallet_provider: encryptedWIAPrivateKey, WIAPublicKey
wallet_provider ->> wallet_provider: buildWIAContent(walletMetadata, statusClaim)
wallet_provider ->>+ hsm: sign WIA content with wiaSigningKey (x5c)
hsm -->>- wallet_provider: WIA JWT
wallet_provider ->>+ hsm: sign disclosure PoP with wiaSigningKey
hsm -->>- wallet_provider: disclosure PoP
wallet_provider ->>+ db: storeWIAKey(encryptedWIAPrivateKey)
db -->>- wallet_provider: OK
wallet_provider-->>-wallet: instruction response: WiaDisclosure (WIA JWT + disclosure PoP)
end