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.
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_codeexchange.
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}(scopeissuedverifiablecredentials:read) returns the full entry.DELETE /issued-verifiable-credentials/{id}(scopeissuedverifiablecredentials: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.