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 (client_assertion) 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 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 |
|
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,
client_assertion) 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
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