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.
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 JWTalg: the signing algorithm, one the issuer listed in proof_signing_alg_values_supportedjwk: 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 issueriat: the time the proof was created, in seconds since 1970nonce: 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
- Credential Issuer to Wallet: Credential Offer, shown as a QR code
- Credential Issuer to Wallet: tx_code, sent to the fleet manager’s phone by text message
- Wallet to Credential Issuer: GET /.well-known/openid-credential-issuer
- Credential Issuer to Wallet: credential issuer metadata
- Wallet to Authorization Server: GET /.well-known/oauth-authorization-server
- Authorization Server to Wallet: authorization server metadata
- Wallet to Authorization Server: POST /token: pre-authorized_code, tx_code
- Authorization Server to Wallet: access_token
- Wallet to Credential Issuer: POST /nonce
- Credential Issuer to Wallet: c_nonce
- Wallet: signs proof JWT
- Wallet to Credential Issuer: POST /credential: access token, proofs
- Credential Issuer to Wallet: credentials, notification_id
- Wallet: validates and stores
- Wallet to Credential Issuer: POST /notify: credential_accepted
- 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.
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
Stored in the wallet
Issuance failed for any other reason, for example the credential did not validate
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
This page is informational and does not constitute legal advice. For authoritative guidance consult the OpenID Foundation and the European Commission directly.