Skip to main content
Version: Latest (v0.0.1)

Issuing Verifiable Credentials

O2ID issues verifiable credentials using OID4VCI, supporting both of its flows. This page walks through the pre-authorized_code flow first — an offer is created for a user, their wallet redeems it for a short-lived access token, then uses that token to fetch the signed credential — then covers the interactive authorization_code flow (a wallet-initiated login, more like an ordinary OAuth sign-in) in its own section below. Both converge on the same POST /verifiable-credential redemption. This page assumes a verifiable credential already exists to issue against.

Steps 2, 3, and 4 happen inside the wallet app, not something triggered manually — they're shown here so it's clear what the wallet is doing. None of them require the wallet to be a registered O2ID application.

note

Creating an offer requires a role with the verifiablecredentialoffers:create scope — see Using o2idctl for how to log in, and Managing Roles for how to create and assign roles.

1. Create the offer

An offer is a one-time, issuer-minted ticket — "redeem this for a signed credential" — created before the wallet ever shows up. That's the defining feature of the pre-authorized_code flow, as opposed to OID4VCI's other flow (the authorization_code flow): the wallet never does an interactive login with O2ID at redemption time at all. Whoever decides this person should get this credential with these claim values decides that up front, out of band — a registrar confirming a student graduated, an HR system confirming someone started today — and the offer just carries that decision to the wallet, typically as a QR code or a deep link.

o2idctl verifiable-credential-offers create \
--verifiable-credential-id <verifiable-credential-id> \
--claim employeeId=E-42 \
--claim department=Engineering

Where O2ID is narrower than that general picture: today, creating an offer through the CLI/API requires the caller to already hold a valid O2ID access token, and the offer is always issued for that caller's own identity — there's no "create an offer for user X" admin call. This is a current O2ID limitation, not something OID4VCI itself requires. Issuing to someone else is done through an onboarding flow instead, where the offer is minted for whoever that flow run resolved as its subject. Either way, only whoever triggers offer creation needs to already be signed in — the wallet redeeming it in steps 2–3 below never authenticates to O2ID interactively.

Each --claim value must be one of the verifiable credential's own defined claim names; anything else is rejected. Claims are frozen into the offer at creation time and expire after 10 minutes if never redeemed.

The response is an OID4VCI credential_offer object, typically turned into a QR code or openid-credential-offer:// deep link for the wallet to scan or open:

{
"credential_issuer": "https://issuer.example.com/t/acme",
"credential_configuration_ids": ["EmployeeBadge"],
"grants": {
"urn:ietf:params:oauth:grant-type:pre-authorized_code": {
"pre-authorized_code": "..."
}
},
"credential_offer_uri": "https://issuer.example.com/t/acme/credential-offer/<id>"
}

credential_offer_uri is a fetch-by-reference alternative to embedding the whole object above — GET it once (unauthenticated) to get back the same JSON, for wallets that prefer a shorter QR/deep-link payload. It's single-use: fetching it twice 404s the second time, independently of the pre-authorized_code's own single-use redemption below — fetching the offer doesn't consume the code.

2. Exchange the pre-authorized_code for an access token

curl -X POST https://issuer.example.com/t/acme/oauth2/token \
-d grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code \
-d pre-authorized_code=<code-from-step-1>

Unlike every other grant type, this one needs no client_id or client_secret — a wallet redeeming an offer isn't a registered O2ID application. The response is a normal access token, scoped to verifiablecredential:issue and valid for 5 minutes, with no refresh token. The pre-authorized_code is single-use: redeeming it twice fails with invalid_grant on the second attempt.

3. Fetch a nonce

curl -X POST https://issuer.example.com/t/acme/verifiable-credential/nonce

Unauthenticated — any wallet can request one, and the nonce alone grants nothing. The response is a fresh, single-use c_nonce:

{
"c_nonce": "...",
"c_nonce_expires_in": 300
}

4. Redeem the token at the credential endpoint

curl -X POST https://issuer.example.com/t/acme/verifiable-credential \
-H "Authorization: Bearer <access-token-from-step-2>" \
-H "Content-Type: application/json" \
-d '{"proof": {"proof_type": "jwt", "jwt": "<wallet-signed proof JWT>"}}'

proof.jwt must be a JWT signed by the wallet's own key, with that key's public half embedded in the JWT header's jwk parameter, and the c_nonce from step 3 embedded as the JWT's own nonce claim — this is how the wallet proves it holds the private key without transmitting it, and how O2ID stops the same proof JWT being replayed against a later request. O2ID verifies the JWT's self-signature, consumes the nonce (rejecting the request if it's missing, expired, or already used), and binds the wallet's key into the issued credential's cnf claim (holder binding, tying the credential to that specific wallet rather than a bearer document anyone could copy). The response:

{
"format": "dc+sd-jwt",
"credential": "<jwt>~<disclosure1>~<disclosure2>~"
}

For dc+sd-jwt, credential is the combined SD-JWT format — the signed JWT followed by one ~-separated disclosure per selectively-disclosable claim, so the wallet can later reveal or withhold individual claims independently. For jwt_vc_json, credential is a bare signed JWT with every claim always visible.

Issuing from a flow

Instead of calling POST /verifiable-credential-offers directly, a flow's credential node, with kind set to verifiable_credential, can create the same offer as a step in a larger sequence — for example, issuing an AgeOver18 badge right after an age-verification flow's outcome, with no separate client-side call needed:

{
"type": "credential",
"onSuccess": "allow",
"onFailure": "deny",
"properties": {
"kind": "verifiable_credential",
"verifiableCredentialId": "<verifiable-credential-id>",
"claimsFromContext": ["ageOver18"]
}
}

claimsFromContext lifts values already sitting in the flow run's context (written by an earlier script, webhook, or outcome step) into the offer's claims — a name with no matching context value is silently omitted, not an error. The node applies the exact same validation as POST /verifiable-credential-offers: the verifiable credential must exist and be enabled, and every claim name must be one of its own defined claims. The subject is always the flow run's own resolved user — there's still no "issue to any other user" mode.

Unlike POST /verifiable-credential-offers, a flow's credential node runs synchronously mid-run: it doesn't pause for client input, and the resulting credential_offer object lands in the run's context at step.<nodeId>.result, the same place a webhook node's response would.

Issuing via the interactive (authorization_code) flow

The pre-authorized_code flow above is issuer-initiated: someone who already knows what claims to freeze decides to issue a credential, out of band, before the wallet ever shows up. The authorization_code flow is the opposite: the wallet starts things off by sending its user through an ordinary O2ID sign-in, and the claims come from that user's own attributes once they're authenticated — genuine self-issuance, not admin-supplied values.

Request it by adding an authorization_details parameter (a JSON array, URL-encoded) to a normal GET /oauth2/authorize call:

GET /oauth2/authorize
?response_type=code
&client_id={clientId}
&redirect_uri={redirectUri}
&code_challenge={challenge}
&code_challenge_method=S256
&authorization_details=%5B%7B%22type%22%3A%22openid_credential%22%2C%22credential_configuration_id%22%3A%22EmployeeBadge%22%7D%5D

That's [{"type": "openid_credential", "credential_configuration_id": "EmployeeBadge"}], URL-encoded — credential_configuration_id is the verifiable credential's own vct, and must name one that's enabled. This isn't a scope: O2ID's scope model is a fixed registry (management-API scopes and resource-server-defined scopes), and a credential's vct isn't registered in either, so RFC 9396's authorization_details is what carries it instead.

A few things behave differently from an ordinary authorization request:

  • Consent is always shown, even for a --trusted (first-party) application and even if the user has previously approved this same application for other scopes. Issuing a credential isn't something that should ever happen silently on a remembered decision — the consent screen shows the credential's name and claim list instead of (or alongside) the usual scope list.
  • The claims come from the signed-in user's own attributes, matched by name against the credential's own defined claims — the same name-based whitelist every other issuance path uses. A claim name with no matching attribute is silently omitted, not an error.
  • The resulting token has no refresh token, matching the pre-authorized_code grant's own one-shot-redemption posture, even though this is otherwise an ordinary authorization_code exchange.

From here on — fetching a nonce, building the proof JWT, redeeming at POST /verifiable-credential — it's identical to steps 3–4 above; the access token from this flow carries the same verifiablecredential:issue scope and works at the same endpoint.

Issuer metadata

GET /.well-known/openid-credential-issuer returns OID4VCI's issuer metadata document — credential_issuer, credential_endpoint, nonce_endpoint, authorization_servers, and one credential_configurations_supported entry per enabled verifiable credential, keyed by its vct. A disabled verifiable credential is omitted, not just marked unavailable. Wallets use this document to discover how to talk to an issuer before redeeming an offer.

Each configuration entry also carries proof_types_supported.jwt.proof_signing_alg_values_supported (the algorithms a wallet's own proof-of-possession JWT may be signed with — RS256/ES256, a separate concern from credential_signing_alg_values_supported, which is what O2ID itself signs the issued credential with) and credential_metadata, listing a display name and one claims[].path entry per defined claim.

Revoking a credential

Every successful POST /verifiable-credential logs an entry (credential type, subject, issued time, status) keyed by the credential's own jti. Two management-API endpoints operate on that log:

  • GET /issued-verifiable-credentials/{id} (scope issuedverifiablecredentials:read) returns the full entry.
  • DELETE /issued-verifiable-credentials/{id} (scope issuedverifiablecredentials:revoke) sets its status to "revoked". Revoking an already-revoked credential is a no-op, not an error.

Revoking here does not invalidate the credential document already held by the wallet — SD-JWT VCs and JWT-VCs are bearer-holder documents, not tokens O2ID can recall. A relying party that wants to honor revocation must check status itself, via the unauthenticated GET /verifiable-credential-status/{id} — deliberately minimal (just {"status": "valid"|"revoked"}, no subject or credential-type information), in place of a full Token Status List implementation.

CLI: o2idctl issued-verifiable-credentials get|revoke <id>.

Requiring a tx_code PIN

Both POST /verifiable-credential-offers and a flow credential node accept "txCodeRequired": true (CLI: --require-tx-code). When set, a random 6-digit numeric PIN is generated and returned once — in the HTTP response's txCode field, or as the flow node's recorded step result/portal view — for out-of-band display alongside the QR code. The credential_offer object itself only ever advertises the PIN's shape (grants["...pre-authorized_code"].tx_code: {"input_mode":"numeric","length":6}), never the value: a wallet must ask the user to enter it separately, and POST /oauth2/token's pre-authorized_code grant rejects a missing or wrong tx_code form parameter with invalid_grant.

Known limitations

  • No Token Status List. Revocation is a simple valid/revoked lookup (see above), not the full bitstring-based status list the OID4VCI ecosystem is trending toward.

API Reference

See the API Reference for the full request/response schemas.