OID4VCI Credential Issuance
OpenID for Verifiable Credential Issuance (OID4VCI) Pre-Authorized Code flow—credential offers, token exchange, format negotiation, and wallet integration.
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.
| Action | Auth | Who calls it |
|---|---|---|
metadata | none | Wallet, before anything else. Also the default when action is omitted |
offer | API key with vc_issue, or a tenant admin session JWT | Your server |
token | none; the pre-authorized code is the credential | Wallet |
credential | Authorization: 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:
| Step | Action | Auth | Result |
|---|---|---|---|
| 1 | metadata | none | Supported formats, binding methods and endpoints |
| 2 | offer | vc_issue scope or admin JWT | A pre-authorized code and an offer URI |
| 3 | token | none | An access token and a c_nonce |
| 4 | credential | Bearer access token | The 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
}'| Field | Type | Default | Meaning |
|---|---|---|---|
credential_type | string | required | Credential type to issue |
subject_did | string | a generated urn:uuid | Subject of the credential |
claims | object | {} | Claims, validated against a registered schema if one exists |
expiration_days | number | 90 | Lifetime, capped by the schema's ttl_days |
issuer_did | string | did:web:passkeybridge.io:tenants:<slug> | Issuer DID; must be this tenant's own DID (400 otherwise) |
formats | array | ["jwt_vc_json"] | Acceptable formats. Unknown entries are dropped |
disclosure_frame | object | absent | Applied when the wallet requests vc+sd-jwt |
holder_binding | boolean | false | When 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.
| Status | Body | Cause |
|---|---|---|
400 | Missing credential_type | Field absent |
400 | No supported formats. Valid: jwt_vc_json, vc+sd-jwt | Every entry in formats was unknown |
400 | Missing x-pb-tenant-id header | API key sent without the tenant header |
401 | Missing authorization or x-pb-api-key header | No credential presented |
403 | Only admins can create offers | Session JWT whose membership role is not admin |
422 | VC not enabled for this tenant | vc_enabled is false |
500 | Failed to create offer | The 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.
| Status | Body | Cause |
|---|---|---|
400 | Unsupported grant_type. Must be 'urn:ietf:params:oauth:grant-type:pre-authorized_code' | Wrong grant type |
400 | Missing pre-authorized_code | Field absent |
400 | Invalid or expired pre-authorized code | No pending offer matches the code, including a code already exchanged |
400 | Pre-authorized code has expired | The offer's five-minute window passed |
500 | Failed to issue token | The 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.
| Status | Body | Cause |
|---|---|---|
400 | Unsupported format '<x>'. This offer supports: ... | Format outside the offer |
400 | Proof JWT missing required c_nonce claim | Proof payload had no nonce |
400 | Invalid c_nonce in proof JWT | Nonce did not match the offer |
400 | Malformed proof JWT followed by cannot validate c_nonce | Proof could not be parsed while a nonce was required |
400 | Holder binding proof is required for this offer | Offer set holder_binding: true and no proof arrived |
401 | Missing Bearer token | No Authorization header |
401 | Invalid or expired access token | No token_issued offer matches, including one already redeemed |
401 | Access token has expired | The token's five-minute window passed |
422 | VC not enabled for this tenant | vc_enabled was turned off between offer and redemption |
422 | Schema 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:
| Action | Actor type | Metadata |
|---|---|---|
oid4vci.offer_created | user or api_key | credential_type, formats, issuer_did |
oid4vci.credential_issued | oid4vci_wallet | format, 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.