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 still 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 — which crates contribute the
handlers, the AttributeService and UpstreamAuthorizationEndpointResolver
seams, where state lives, and how a single /token request flows through the
components — see
PID Issuer architecture.
Delegation to RDO Max: what is decoupled, what is passed through
Conceptually the PID Issuer runs its own Authorization Server in front of RDO
Max. Most OAuth parameters are therefore decoupled: the wallet’s values are
terminated at the PID Issuer, which substitutes its own for the upstream
server. A few parameters are shared so that RDO Max can redirect the browser
straight back to the wallet without any intermediary redirects to the PID
Issuer’s domain, and lets RDO Max’s error/cancel responses reach the wallet
directly.
Parameter handling on upstream /authorize:
Parameter |
Handling |
|---|---|
|
decoupled — wallet’s client id → PID Issuer’s DigiD client id |
|
shared — wallet’s universal link, passed through |
|
decoupled — PID Issuer generates its own PKCE pair for the upstream |
|
shared — so the wallet can verify it when the code comes back |
|
shared |
|
terminated — replaced with |
|
PID Issuer generates its own for the upstream session |
|
terminated — wallet ↔ PID Issuer only |
Parameter handling on upstream /token:
Parameter |
Handling |
|---|---|
|
decoupled — rewritten as above |
|
shared — the upstream code flows wallet → PID Issuer → RDO Max |
|
shared |
|
decoupled — wallet sends its verifier; PID Issuer swaps in its own |
|
shared |
|
terminated |
|
terminated — RDO Max is not DPoP-aware; upstream client auth used |
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: wallet uses (v1, c1), the PID Issuer uses (v2, c2) on
the upstream server.
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: bind session S,<br/>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-->>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)<br/>and upstream nonce, attach to session S
note right of PI: rewrite on the way to RDO Max:<br/>client_id nl-wallet-app → pid-issuer-digid<br/>code_challenge c1 → c2<br/>scope terminated → scope=openid<br/>redirect_uri (passed through)<br/>state (passed through)<br/>response_type (passed through)
PI->>OS: 302 Location: <RDO>/authorize?<br/>response_type=code&client_id=pid-issuer-digid&<br/>redirect_uri=<wallet_ul>&<br/>code_challenge=c2&code_challenge_method=S256&<br/>state=s1&scope=openid
OS->>RDO: GET /authorize?...
note over OS,RDO: user authenticates via DigiD
RDO->>OS: 302 Location: <wallet_ul>?<br/>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: verify code_verifier v1 against c1,<br/>load session S → v2
note right of PI: rewrite on the way to RDO Max:<br/>client_id nl-wallet-app → pid-issuer-digid<br/>code_verifier v1 → v2<br/>code, redirect_uri, grant_type (passed through),<br/>upstream client auth applied
PI->>RDO: POST /token
note right of PI: grant_type=authorization_code<br/>code=code1<br/>redirect_uri=<wallet_ul><br/>code_verifier=v2<br/>client_id=pid-issuer-digid<br/>(with 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
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 or
PerformIssuanceWithWia instruction,
depending on whether or not a PID is being issued (which requires a WIA). Using
one of these instructions, the App requests the Wallet Backend to provide a WIA
and Proofs of Possession (PoPs) for the private keys by signing the c_nonce
from the issuer. 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[_with_wia](c_nonce, key_count)
wallet_provider->>wallet_provider: key_count++ if WIA is requested
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
opt WIA requested
wallet_provider ->> wallet_provider: generateWIAContent()
wallet_provider ->>+ hsm: sign WIA content using wiaSigningPrivateKey
hsm -->>- wallet_provider: WIA
end
opt More than 1 private key involved
wallet_provider ->>+ hsm: sign PoA for attestationPrivateKeys and possibly WIA
hsm -->>- wallet_provider: PoA
end
wallet_provider-->>-wallet: instruction response: PoPs, attestationPublicKeys, WIA (optional), PoA (optional)