Skip to main content

OpenID4VCI credential issuance explained: from offer to accepted credential

OpenID4VCI, OpenID for Verifiable Credential Issuance, defines how a wallet requests and receives a credential from an issuer. A single issuance runs through several distinct steps before the wallet actually holds a usable credential, and each one has its own failure mode. This page walks through that lifecycle in full, with original worked examples.

Two ways to start: pre-authorized code and authorization code

Issuance often starts from a Credential Offer sent by the issuer, and the grant it names decides how the wallet gets authorized. A wallet can also start issuance itself, without any offer, using the authorization code flow. Both flows end at the same place: the wallet holding an access token it can use to request the credential.

Pre-authorized code flow

1. Issuer

Already knows the holder, hands out a Credential Offer with a pre-authorized_code

2. Wallet

Redeems the code at the token endpoint, optionally with a transaction code

3. Wallet

Requests the credential with a proof of possession of its key

Authorization code flow

1. Wallet

Scans a Credential Offer naming an authorization_code grant, or starts the flow itself without an offer

2. Authorization Server

Takes the holder through login and consent, then issues a code

3. Wallet

Exchanges the code for a token, then requests the credential

The full lifecycle of a credential offer

The specification does not define named states, but an issuer-initiated flow where the credential is issued straight away is easiest to follow as the sequence below. Each step can fail in its own way, and a wallet implementation has to handle those paths, not only the happy one.

Offer created
QR code or link opens wallet
Offer received
grant redeemed
Token obtained
nonce and proof of possession
Credential requested
credential minted
Credential issued
wallet validates and stores
Credential accepted
Where it can fail, in the order the steps run:
Code expired before redemption
Token request denied
Credential request rejected
Credential not stored

Worked example: a roadworthiness certificate for a haulage fleet

A vehicle inspection body issues a roadworthiness certificate to a haulage company’s business wallet after a routine inspection. The inspector already authenticated the fleet manager at the inspection point, so the issuer uses the pre-authorized code flow. The steps below follow that single issuance from offer to accepted credential.

The example shows base OpenID4VCI. High-assurance deployments such as the EUDI Wallet follow the HAIP profile on top of it, which adds DPoP-bound access tokens, wallet attestation at the token endpoint and key attestation for the credential keys. Those are left out here to keep each step readable.

1. Credential offer

The inspection body’s terminal shows a QR code. It holds a URI starting with openid-credential-offer:// that carries the offer below, URL-encoded in a credential_offer parameter, or a credential_offer_uri from which the wallet fetches it. The wallet scans it and reads which credential is on offer and how to collect it.

Issuer

Shows a QR code with a Credential Offer

Names the credential configuration and a pre-authorized_code grant

{
  "credential_issuer": "https://issuer.fleetinspect.example",
  "credential_configuration_ids": ["roadworthiness_certificate"],
  "grants": {
    "urn:ietf:params:oauth:grant-type:pre-authorized_code": {
      "pre-authorized_code": "fi-8f2c0b3a",
      "tx_code": {
        "length": 6,
        "input_mode": "numeric",
        "description": "Enter the code sent to your phone by text message"
      }
    }
  }
}

2. Issuer metadata discovery

Before requesting anything, the wallet fetches the issuer’s metadata to learn what roadworthiness_certificate contains and which endpoints to call. The metadata lists no separate authorization servers, so the issuer is its own authorization server, and the wallet reads the token endpoint from that server’s metadata.

Wallet

GET /.well-known/openid-credential-issuer

Learns the credential format, claims and accepted proof types, plus the nonce, credential, deferred and notification endpoints

Credential issuer metadata (excerpt)

{
  "credential_issuer": "https://issuer.fleetinspect.example",
  "nonce_endpoint": "https://issuer.fleetinspect.example/nonce",
  "credential_endpoint": "https://issuer.fleetinspect.example/credential",
  "deferred_credential_endpoint": "https://issuer.fleetinspect.example/deferred",
  "notification_endpoint": "https://issuer.fleetinspect.example/notify",
  "credential_configurations_supported": {
    "roadworthiness_certificate": {
      "format": "dc+sd-jwt",
      "vct": "https://fleetinspect.example/vct/roadworthiness",
      "cryptographic_binding_methods_supported": ["jwk"],
      "credential_signing_alg_values_supported": ["ES256"],
      "proof_types_supported": {
        "jwt": { "proof_signing_alg_values_supported": ["ES256"] }
      },
      "credential_metadata": {
        "claims": [
          { "path": ["vehicle_registration"] },
          { "path": ["inspection_result"] },
          { "path": ["valid_until"] }
        ]
      }
    }
  }
}

Authorization server metadata (excerpt), from /.well-known/oauth-authorization-server

{
  "issuer": "https://issuer.fleetinspect.example",
  "token_endpoint": "https://issuer.fleetinspect.example/token",
  "pre-authorized_grant_anonymous_access_supported": true
}

3. Token request

The wallet redeems the pre-authorized code at the token endpoint, together with the transaction code the inspection body sent to the fleet manager’s phone. Sending that code over a second channel means someone who photographs the QR code over their shoulder still cannot redeem it.

Wallet

POST /token

Sends the pre-authorized_code and tx_code, receives an access token scoped to this offer

POST /token HTTP/1.1
Host: issuer.fleetinspect.example
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code
&pre-authorized_code=fi-8f2c0b3a
&tx_code=482913

Response

{
  "access_token": "fi-at-3d91e0",
  "token_type": "Bearer",
  "expires_in": 86400
}

4. Proof of possession

Because the metadata lists a nonce_endpoint, the wallet first fetches a fresh c_nonce there. It then proves it holds the private key the credential will be bound to, by signing a proof JWT over the issuer identifier and that c_nonce.

Wallet

POST /nonce, then signs a proof JWT with the key the credential will be bound to

Binds the credential to that key, not merely to whoever holds the access token

POST /nonce HTTP/1.1
Host: issuer.fleetinspect.example

Response

{
  "c_nonce": "fi-nonce-77aa"
}

The wallet now builds the proof JWT from two JSON objects, a header and a payload, and signs them with the private key the credential will be bound to.

Header: what this JWT is and which key signed it

{
  "typ": "openid4vci-proof+jwt",
  "alg": "ES256",
  "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
  • typ: marks this as an OpenID4VCI key proof, so it cannot be mistaken for any other kind of JWT
  • alg: the signing algorithm, one the issuer listed in proof_signing_alg_values_supported
  • jwk: the public key the credential will be bound to; the issuer checks the signature against it

Payload: who the proof is for and when it was made

{
  "aud": "https://issuer.fleetinspect.example",
  "iat": 1789376400,
  "nonce": "fi-nonce-77aa"
}
  • aud: the issuer identifier, so the proof cannot be replayed at a different issuer
  • iat: the time the proof was created, in seconds since 1970
  • nonce: the c_nonce from the nonce endpoint, which shows the proof is fresh

Signed result

Header and payload are each base64url-encoded and joined with a dot. The wallet signs that string with its private key and appends the base64url-encoded signature after a second dot. The resulting string is the proof JWT the wallet sends in the credential request in step 5.

base64url(header) . base64url(payload) . base64url(signature)

eyJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCIs...
  .eyJhdWQiOiJodHRwczovL2lzc3Vlci5mbGVldGluc3BlY3QuZXhhbXBsZSIs...
  .<ES256 signature>

5. Credential request

The wallet calls the credential endpoint with the access token and the proof, and the issuer mints and returns the signed credential.

Wallet

POST /credential

Sends the access token, the configuration id and the proof JWT, receives the signed credential and a notification_id

POST /credential HTTP/1.1
Host: issuer.fleetinspect.example
Content-Type: application/json
Authorization: Bearer fi-at-3d91e0

{
  "credential_configuration_id": "roadworthiness_certificate",
  "proofs": {
    "jwt": ["<proof JWT from step 4>"]
  }
}

Response

{
  "credentials": [
    { "credential": "<issuer-signed SD-JWT VC>" }
  ],
  "notification_id": "fi-notif-9012"
}

The whole flow at a glance

This sequence diagram puts the five steps of the worked example together, from the offer to the notification. Solid arrows are requests, dashed arrows are responses, and the dotted arrow is the transaction code travelling outside the protocol by text message.

Wallet

Haulage company’s business wallet

Authorization Server

Run by the issuer itself in this example

Credential Issuer

Vehicle inspection body

  1. Credential Issuer to Wallet: Credential Offer, shown as a QR code
  2. Credential Issuer to Wallet: tx_code, sent to the fleet manager’s phone by text message
  3. Wallet to Credential Issuer: GET /.well-known/openid-credential-issuer
  4. Credential Issuer to Wallet: credential issuer metadata
  5. Wallet to Authorization Server: GET /.well-known/oauth-authorization-server
  6. Authorization Server to Wallet: authorization server metadata
  7. Wallet to Authorization Server: POST /token: pre-authorized_code, tx_code
  8. Authorization Server to Wallet: access_token
  9. Wallet to Credential Issuer: POST /nonce
  10. Credential Issuer to Wallet: c_nonce
  11. Wallet: signs proof JWT
  12. Wallet to Credential Issuer: POST /credential: access token, proofs
  13. Credential Issuer to Wallet: credentials, notification_id
  14. Wallet: validates and stores
  15. Wallet to Credential Issuer: POST /notify: credential_accepted
  16. Credential Issuer to Wallet: 204 No Content

When the credential is not ready yet: deferred issuance

The example above assumes the inspection result is already final. If the inspection body instead needs to escalate a borderline result to a senior inspector, the credential endpoint cannot return the credential straight away, so it defers issuance.

Immediate issuance

The credential endpoint returns the signed credential in the same response as the request.

Deferred issuance

The credential endpoint answers with HTTP 202, a transaction_id and an interval instead. The wallet polls the deferred credential endpoint with that id, waiting at least interval seconds between requests, until the credential is ready.

transaction_idpoll deferred_credential_endpoint

Deferred response from /credential

HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "transaction_id": "fi-tx-55c2",
  "interval": 900
}

Polling /deferred until it is ready

POST /deferred HTTP/1.1
Host: issuer.fleetinspect.example
Content-Type: application/json
Authorization: Bearer fi-at-3d91e0

{ "transaction_id": "fi-tx-55c2" }

// While the review is open: 202 with the same transaction_id and interval.
// Once approved: 200 with the credentials, optionally with a notification_id.
// If the review rejects the result: a credential_request_denied error.

Closing the loop: the notification endpoint

After issuance, the wallet can tell the issuer what happened to the credential, using the notification_id from the credential response. Wallets are not required to send these notifications and delivery is not guaranteed, so an issuer cannot treat a missing notification as meaning anything.

Wallet

Posts an event to the issuer’s notification_endpoint for the notification_id it received, which covers every credential in that response

credential_accepted

Stored in the wallet

credential_failure

Issuance failed for any other reason, for example the credential did not validate

credential_deleted

Issuance failed because of the holder, for example they declined to store it

POST /notify HTTP/1.1
Host: issuer.fleetinspect.example
Content-Type: application/json
Authorization: Bearer fi-at-3d91e0

{
  "notification_id": "fi-notif-9012",
  "event": "credential_accepted"
}

Related terms

Frequently asked questions

How does a wallet know which grant type to use?

The Credential Offer names the grant in its grants object. authorization_code is present when the issuer wants the holder to log in as part of the flow. pre-authorized_code is present when the holder was already authenticated on the channel where the offer was created, for example by the inspector at the inspection point in the worked example below. An offer can list both, and the wallet then picks one. If the offer has no grants object at all, the wallet looks up which grant types the authorization server supports in its metadata.

Why does the wallet fetch issuer metadata before requesting anything?

The Credential Offer only names credential_configuration_ids, the issuer’s URL and the grants. The issuer’s metadata, served from a well-known path, is what describes each configuration: its format, its claims and the proof types it accepts, plus the nonce, credential, deferred and notification endpoints. It also says which authorization server to use, and that server’s own metadata gives the token endpoint. Without both, the wallet would not know how to build valid requests or what to show the holder before they consent.

What does the proof of possession actually prove?

It proves the wallet requesting the credential holds the private key the credential will be bound to, not merely that it has a valid access token. The wallet signs a proof JWT over the issuer identifier and a fresh c_nonce from the issuer’s nonce endpoint, using that key. The issuer embeds the matching public key in the credential. A verifier that requires key binding asks the holder to sign with the same key again when presenting, so a copied credential without the key fails that check.

Why would an issuer defer issuance instead of returning the credential right away?

Some checks the issuer runs before minting a credential cannot complete inside a single HTTP request, for example a manual review or a call to a slow external registry. Deferred issuance lets the credential endpoint respond immediately with a transaction_id instead of blocking the connection, and the wallet polls the deferred endpoint with that id until the check finishes and the credential is ready to collect.

Is the notification endpoint mandatory for an issuer to implement?

No. It is optional for issuers, and wallets are not required to use it either. When both support it, the issuer learns what happened after issuance: the credentials were stored (credential_accepted), the holder stopped the issuance, for example by declining to store them (credential_deleted), or it failed for another reason (credential_failure). Delivery is not guaranteed, so an issuer should treat a notification as useful information, never as a reliable record, and cannot read anything into a missing one.

Sources

  1. OpenID for Verifiable Credential Issuance 1.0
  2. OpenID4VC High Assurance Interoperability Profile 1.0
  3. EUDI Wallet Architecture and Reference Framework

This page is informational and does not constitute legal advice. For authoritative guidance consult the OpenID Foundation and the European Commission directly.

Talk to us about EUDI Wallet integration