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.

Pre-authorized code flow

In the Pre-Authorized Code Flow the issuer generates a pre-authorized code out of band, hands it to the wallet inside an OpenID4VCI Credential Offer, and the wallet sends that code to the issuer’s /token endpoint with grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code. The issuer’s AttributeService resolves the pre-authorized code into the attributes to be issued. The wallet then obtains a c_nonce from the issuer’s nonce endpoint and exchanges proofs of possession for attestations at the /credential endpoint.

In our codebase, this flow is implemented by the issuance_server (the disclosure-based-issuance service); see Disclosure-based Issuance for the end-to-end picture, including how the disclosed attributes are turned into attestations via the attestation server. The pid_issuer does not accept the pre-authorized code grant — it only accepts the authorization code flow described below.

        sequenceDiagram
    autonumber

    participant OS
    participant Wallet
    participant CI as Credential Issuer
    participant AS as Authorization Server

    Note over OS,AS: Credential Offer (out of band)

    CI-->>OS: openid-credential-offer://?credential_offer_uri=<url><br/>(e.g. via QR code or universal link)
    OS->>Wallet: open with offer URL
    Wallet->>CI: GET <credential_offer_uri>
    CI->>Wallet: Credential Offer<br/>{ credential_issuer, credential_configuration_ids,<br/>  grants: { pre-authorized_code:<br/>    { pre-authorized_code, ... } } }

    Note over OS,AS: Discovery

    Wallet-->>CI: discover OpenID4VCI metadata
    CI-->>Wallet: issuer metadata
    Wallet->>Wallet: discover Authorization Server
    Wallet-->>AS: discover OAuth metadata
    AS-->>Wallet: oauth metadata

    Note over OS,AS: Token exchange

    Wallet->>AS: POST Token Request<br/>(grant_type=...:pre-authorized_code,<br/>pre-authorized_code, WIA)
    AS->>Wallet: Token Response (access_token)

    Note over OS,AS: Issuance phase

    Wallet->>CI: POST /previews(access_token)
    CI->>Wallet: previews

    loop for every credential
        Wallet->>CI: POST Nonce Request
        CI->>Wallet: c_nonce
        Wallet-->>CI: GET metadata
        CI-->>Wallet: metadata
        Wallet->>CI: POST Credential Request<br/>(access_token, WUA) to /credential
        CI->>Wallet: Credential Response (attestation copies)
    end
    

Authorization code flow (HAIP)

The HAIP profile mandates the regular Authorization Code Flow with a number of additional requirements:

  • A Pushed Authorization Request (PAR) is required; all authorization parameters are sent server-to-server and the browser only carries a request_uri;

  • PKCE with S256 is required;

  • The wallet authenticates itself at both /par and /token with a Wallet Instance Attestation (WIA) carried in a client_assertion (OAuth 2.0 Attestation-Based Client Authentication);

  • Access tokens are sender-constrained with DPoP. The /token response has token_type=DPoP, and every subsequent request at the credential issuer carries both an Authorization: DPoP <access_token> header and a fresh DPoP proof JWT (which includes ath, the hash of the access token);

  • OpenID4VCI 1.0 moves c_nonce out of the /token response into a dedicated nonce endpoint at the credential issuer;

  • Issuance uses a single /credential endpoint that accepts a proofs array, carrying one Proof of Possession per attestation private key.

        sequenceDiagram
    autonumber

    participant OS
    participant Wallet
    participant CI as Credential Issuer
    participant AS as Authorization Server

    Wallet-->>CI: discover OpenID4VCI metadata
    CI-->>Wallet: { credential_issuer,<br/>  credential_configurations_supported,<br/>  authorization_servers, nonce_endpoint,<br/>  credential_endpoint, ... }
    Wallet->>Wallet: discover Authorization Server

    Note over OS,AS: Authentication phase

    Wallet-->>AS: discover OAuth metadata
    AS-->>Wallet: { pushed_authorization_request_endpoint,<br/>  authorization_endpoint, token_endpoint, ... }
    Wallet->>AS: POST PAR (WIA)
    AS->>Wallet: request_uri
    Wallet->>OS: open browser (URL)
    OS->>AS: (browser) GET Authorization Request (request_uri)
    AS->>OS: Authorization Response (code)
    OS->>Wallet: openWallet(code)
    Wallet->>AS: POST Token Request (code, WIA)
    AS->>Wallet: Token Response (access_token)

    Note over OS,AS: Issuance phase

    Wallet->>CI: POST /previews(access_token)
    CI->>Wallet: previews

    loop for every credential
        Wallet->>CI: POST Nonce Request
        CI->>Wallet: c_nonce
        Wallet-->>CI: GET metadata
        CI-->>Wallet: metadata
        Wallet->>CI: POST Credential Request<br/>(access_token, WUA) to /credential
        CI->>Wallet: Credential Response (attestation copies)
    end
    

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

client_id

decoupled — wallet’s client id → PID Issuer’s DigiD client id

redirect_uri

shared — wallet’s universal link, passed through

code_challenge/_method

decoupled — PID Issuer generates its own PKCE pair for the upstream

state

shared — so the wallet can verify it when the code comes back

response_type=code

shared

scope

terminated — replaced with openid

nonce (OIDC)

PID Issuer generates its own for the upstream session

client_assertion (WIA)

terminated — wallet ↔ PID Issuer only

Parameter handling on upstream /token:

Parameter

Handling

client_id

decoupled — rewritten as above

code

shared — the upstream code flows wallet → PID Issuer → RDO Max

redirect_uri

shared

code_verifier

decoupled — wallet sends its verifier; PID Issuer swaps in its own

grant_type=authorization_code

shared

client_assertion (WIA)

terminated

DPoP header

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)