OID4VCI Credential Issuance

OpenID for Verifiable Credential Issuance (OID4VCI) Pre-Authorized Code flow—credential offers, token exchange, format negotiation, and wallet integration.

Last reviewed September 17, 2026Fresh

Overview

shield-vc-oid4vci issues credentials to a wallet over OpenID for Verifiable Credential Issuance, using the Pre-Authorized Code flow: your server creates an offer, the wallet swaps the pre-authorized code for an access token, and the token buys exactly one credential.

Everything is one endpoint, POST https://api.passkeybridge.io/v1/shield-vc-oid4vci, routed by an action field in the body. The metadata document also lists that URL as both credential_endpoint and token_endpoint.

ActionAuthWho calls it
metadatanoneWallet, before anything else. Also the default when action is omitted
offerAPI key with vc_issue, or a tenant admin session JWTYour server
tokennone; the pre-authorized code is the credentialWallet
credentialAuthorization: Bearer <access_token>Wallet

The tenant must have credentials enabled: offer and credential both check vc_enabled and answer 422 VC not enabled for this tenant otherwise. See Verifiable Credentials for how the flag is set.

Credentials issued this way are identical to those from shield-vc-issue: an ES256 or ES384 JWS plus a detached ML-DSA proof, or the legacy PQC-HYBRID envelope for tenants who opted into it.

Pre-authorized code flow

Four calls, in order:

StepActionAuthResult
1metadatanoneSupported formats, binding methods and endpoints
2offervc_issue scope or admin JWTA pre-authorized code and an offer URI
3tokennoneAn access token and a c_nonce
4credentialBearer access tokenThe credential

An offer row moves pending to token_issued to redeemed, and each transition is one-way. A second token exchange on the same code answers 400 Invalid or expired pre-authorized code, because the row is no longer pending. A second credential request on the same token answers 401 Invalid or expired access token, because the row is no longer token_issued.

Both the pre-authorized code and the access token expire five minutes after they are issued; the row's expires_at is overwritten at each step. An expired row is marked expired when it is next touched, and the call answers 400 Pre-authorized code has expired or 401 Access token has expired.

Issuer metadata

curl -X POST https://api.passkeybridge.io/v1/shield-vc-oid4vci \
  -H "Content-Type: application/json" \
  -d '{"action":"metadata","tenant_slug":"acme"}'

tenant_slug only labels the display entry; the document is otherwise the same for every tenant.

{
  "credential_issuer": "https://api.passkeybridge.io/v1",
  "credential_endpoint": "https://api.passkeybridge.io/v1/shield-vc-oid4vci",
  "token_endpoint": "https://api.passkeybridge.io/v1/shield-vc-oid4vci",
  "credential_configurations_supported": {
    "jwt_vc_json": {
      "format": "jwt_vc_json",
      "credential_definition": { "type": ["VerifiableCredential"] },
      "cryptographic_binding_methods_supported": ["did:web", "did:key"],
      "credential_signing_alg_values_supported": ["PQC-HYBRID"],
      "display": [{ "name": "PasskeyBridge Verifiable Credential", "locale": "en-US" }]
    },
    "vc+sd-jwt": {
      "format": "vc+sd-jwt",
      "credential_definition": { "type": ["VerifiableCredential"] },
      "cryptographic_binding_methods_supported": ["did:web", "did:key"],
      "credential_signing_alg_values_supported": ["PQC-HYBRID"],
      "cryptographic_suites_supported": ["ES256"],
      "display": [{ "name": "PasskeyBridge SD-JWT Credential", "locale": "en-US" }]
    },
    "pre_authorized_grant_types_supported": ["urn:ietf:params:oauth:grant-type:pre-authorized_code"]
  }
}

Read credential_signing_alg_values_supported with care. The document advertises PQC-HYBRID for both configurations, while a tenant on the default settings issues an ES256 or ES384 JWS with a detached ML-DSA proof. Take the alg from the credential header you receive, and treat the metadata value as the legacy envelope's identifier rather than a promise about what you will be handed. cryptographic_suites_supported: ["ES256"] appears only under vc+sd-jwt.

Creating credential offers

curl -X POST https://api.passkeybridge.io/v1/shield-vc-oid4vci \
  -H "x-pb-api-key: pb_live_..." \
  -H "x-pb-tenant-id: <tenant uuid>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "offer",
    "credential_type": "IdentityAttestation",
    "subject_did": "did:web:example.com:users:alice",
    "claims": { "verificationLevel": "enhanced" },
    "formats": ["jwt_vc_json", "vc+sd-jwt"],
    "expiration_days": 90
  }'
FieldTypeDefaultMeaning
credential_typestringrequiredCredential type to issue
subject_didstringa generated urn:uuidSubject of the credential
claimsobject{}Claims, validated against a registered schema if one exists
expiration_daysnumber90Lifetime, capped by the schema's ttl_days
issuer_didstringdid:web:passkeybridge.io:tenants:<slug>Issuer DID; must be this tenant's own DID (400 otherwise)
formatsarray["jwt_vc_json"]Acceptable formats. Unknown entries are dropped
disclosure_frameobjectabsentApplied when the wallet requests vc+sd-jwt
holder_bindingbooleanfalseWhen true, a credential request with no proof is refused
{
  "credential_offer": {
    "credential_issuer": "https://api.passkeybridge.io/v1",
    "credential_configuration_ids": ["jwt_vc_json", "vc+sd-jwt"],
    "grants": {
      "urn:ietf:params:oauth:grant-type:pre-authorized_code": {
        "pre-authorized_code": "9f2c...64 hex chars"
      }
    }
  },
  "credential_offer_uri": "openid-credential-offer://?credential_offer=%7B...%7D",
  "pre_authorized_code": "9f2c...64 hex chars",
  "expires_at": "2026-09-16T10:05:00.000Z",
  "supported_formats": ["jwt_vc_json", "vc+sd-jwt"]
}

Render credential_offer_uri as a QR code, or hand pre_authorized_code straight to a wallet you control. Only the code's SHA-256 is stored, so this response is the one chance to capture it.

StatusBodyCause
400Missing credential_typeField absent
400No supported formats. Valid: jwt_vc_json, vc+sd-jwtEvery entry in formats was unknown
400Missing x-pb-tenant-id headerAPI key sent without the tenant header
401Missing authorization or x-pb-api-key headerNo credential presented
403Only admins can create offersSession JWT whose membership role is not admin
422VC not enabled for this tenantvc_enabled is false
500Failed to create offerThe offer row could not be written

Token exchange

The wallet exchanges the code with no authentication of its own:

curl -X POST https://api.passkeybridge.io/v1/shield-vc-oid4vci \
  -H "Content-Type: application/json" \
  -d '{
    "action": "token",
    "grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
    "pre-authorized_code": "<code from the offer>"
  }'
{
  "access_token": "c1d0...64 hex chars",
  "token_type": "Bearer",
  "expires_in": 300,
  "c_nonce": "0f6a5b7c-...",
  "c_nonce_expires_in": 300,
  "authorization_details": [
    {
      "type": "openid_credential",
      "credential_configuration_id": "jwt_vc_json",
      "credential_definition": { "type": ["VerifiableCredential", "IdentityAttestation"] }
    }
  ]
}

Keep the c_nonce: a holder proof must echo it. Only hashes of the token and nonce are stored.

StatusBodyCause
400Unsupported grant_type. Must be 'urn:ietf:params:oauth:grant-type:pre-authorized_code'Wrong grant type
400Missing pre-authorized_codeField absent
400Invalid or expired pre-authorized codeNo pending offer matches the code, including a code already exchanged
400Pre-authorized code has expiredThe offer's five-minute window passed
500Failed to issue tokenThe state transition could not be written

Credential request and format negotiation

curl -X POST https://api.passkeybridge.io/v1/shield-vc-oid4vci \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "credential",
    "format": "vc+sd-jwt",
    "proof": { "proof_type": "jwt", "jwt": "<holder proof JWT>" }
  }'

format must be one of the offer's supported_formats; omitted, the offer's first configuration is used. The response is the OID4VCI credential response:

{
  "format": "vc+sd-jwt",
  "credential": "eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK3NkLWp3dCI...~WyJz...~",
  "c_nonce": "7b1e2f80-...",
  "c_nonce_expires_in": 300,
  "sd_disclosures": [
    { "claim": "verificationLevel", "encoded": "WyJz...", "digest": "kS3F..." }
  ]
}

sd_disclosures appears only for vc+sd-jwt. The offer moves to redeemed and cannot be used again.

Holder binding, as implemented. When a proof of type jwt is present, the function verifies the proof JWT's signature against the key in its own header before reading any claim from it, then compares the SHA-256 of the proof's nonce against the stored c_nonce hash. The header must carry typ: openid4vci-proof+jwt and an alg of ES256 or ES384 matching the key's curve; a key carrying a private component is refused, and the key is reduced to kty, crv, x and y before it becomes the credential's cnf.jwk, so nothing else in the header rides along. The payload's aud must be https://api.passkeybridge.io/v1, and iat must be a number that is not in the future by more than five minutes. A proof you send that fails any of these is a 400, never a silent downgrade to an unbound credential.

The JWK becomes cnf.jwk in the credential only when the credential is issued as vc+sd-jwt from an offer that carries a disclosure_frame. A jwt_vc_json credential never carries cnf, whatever proof was sent, so plan for holder binding by issuing SD-JWT-VC.

StatusBodyCause
400Unsupported format '<x>'. This offer supports: ...Format outside the offer
400Proof JWT missing required c_nonce claimProof payload had no nonce
400Invalid c_nonce in proof JWTNonce did not match the offer
400Malformed proof JWT followed by cannot validate c_nonceProof could not be parsed while a nonce was required
400Holder binding proof is required for this offerOffer set holder_binding: true and no proof arrived
401Missing Bearer tokenNo Authorization header
401Invalid or expired access tokenNo token_issued offer matches, including one already redeemed
401Access token has expiredThe token's five-minute window passed
422VC not enabled for this tenantvc_enabled was turned off between offer and redemption
422Schema validation failed: ...Offer claims do not satisfy the registered schema

Claims are validated against the schema registry at redemption, not at offer time, so a bad claim set surfaces to the wallet rather than to your server.

End-to-end call sequence

The whole flow from a shell, with the values you carry between steps:

# 1. Metadata (public)
curl -X POST https://api.passkeybridge.io/v1/shield-vc-oid4vci \
  -H "Content-Type: application/json" \
  -d '{"action":"metadata","tenant_slug":"acme"}'

# 2. Offer (vc_issue scope). Keep pre_authorized_code from the response.
curl -X POST https://api.passkeybridge.io/v1/shield-vc-oid4vci \
  -H "x-pb-api-key: pb_live_..." \
  -H "x-pb-tenant-id: <tenant uuid>" \
  -H "Content-Type: application/json" \
  -d '{"action":"offer","credential_type":"IdentityAttestation","formats":["jwt_vc_json"]}'

# 3. Token (public). Keep access_token and c_nonce.
curl -X POST https://api.passkeybridge.io/v1/shield-vc-oid4vci \
  -H "Content-Type: application/json" \
  -d '{"action":"token","grant_type":"urn:ietf:params:oauth:grant-type:pre-authorized_code","pre-authorized_code":"<code>"}'

# 4. Credential (Bearer access token)
curl -X POST https://api.passkeybridge.io/v1/shield-vc-oid4vci \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"action":"credential","format":"jwt_vc_json"}'

Steps 3 and 4 have five-minute windows, so run them together rather than parking the code.

The SDK wraps the wallet half of this: createCredentialOffer for step 2, and redeemOffer for steps 3 and 4 in one call, generating the holder proof when the format is vc+sd-jwt. See VC Wallet SDK Quickstart.

Audit trail

Two audit actions are written to shield_audit_log:

ActionActor typeMetadata
oid4vci.offer_createduser or api_keycredential_type, formats, issuer_did
oid4vci.credential_issuedoid4vci_walletformat, credential_type, has_holder_binding

oid4vci.offer_created records the first 16 characters of the code hash as its resource_id; oid4vci.credential_issued records the credential id. The oid4vci_wallet actor type is what separates wallet-driven issuance from a direct shield-vc-issue call in a compliance export.

Confirm a redemption worked: the credential appears in Dashboard > Credentials > Issued credentials with its type and PQC algorithm, and Dashboard > Audit log shows the oid4vci.credential_issued entry. The issued credential also carries a vc_issued event, written by the shared issuance path.

Related from the blog