Skip to content

OID4VCI

ProtocolSoup’s OID4VCI Issuer and Wallet implementations are OpenID Certified™ through the OpenID Foundation certification programme (HAIP, SD-JWT VC, and mdoc). Certification applies to the named versions and profiles, not every ProtocolSoup feature.

  • OpenID for Verifiable Credential Issuance 1.0 (final)
  • OAuth 2.0 Authorization Framework (token and grant semantics)
  • RFC 8414 OAuth 2.0 Authorization Server Metadata
  • OAuth 2.0 Attestation-Based Client Authentication (draft-ietf-oauth-attestation-based-client-auth)
  • OID4VCI 1.0 Appendix D Key Attestation
  • RFC 9449 OAuth 2.0 Demonstrating Proof of Possession (DPoP), opt-in
  • OpenID4VC High Assurance Interoperability Profile (HAIP) 1.0 (final)
  • SD-JWT VC profile (dc+sd-jwt credential format)
  • W3C Verifiable Credentials Data Model 1.1 / 2.0 (jwt_vc_json, jwt_vc_json-ld, ldp_vc formats)
  • W3C VC Data Integrity 1.0 (ecdsa-rdfc-2019, eddsa-rdfc-2022 cryptosuites for ldp_vc)
  • ISO/IEC 18013-5 mobile driving licence (mso_mdoc credential format, doctype org.iso.18013.5.1.mDL)
Flow ID Name Steps Description
oid4vci-pre-authorized Pre-Authorized Code 6 Create Credential Offer, then observe wallet→issuer metadata, token, nonce, proof, and credential hops
oid4vci-pre-authorized-tx-code Pre-Authorized + tx_code 6 Same stages with mandatory tx_code on the token request
oid4vci-deferred-issuance Deferred Issuance 8 Same stages plus deferred_credential polling until credentials are ready
oid4vci-issuer-initiated Issuer-Initiated Offer 4 Create an authorization_code Credential Offer with a fresh issuer_state. Under Advanced, optionally paste a Credential Offer Endpoint for a real HTTPS GET delivery, or leave it blank for an openid-credential-offer:// invocation/QR. Then click Check Result to observe real issuer-observed PAR, token, and credential issuance milestones. Looking Glass does not redeem the code in-browser.
  • Pre-Authorized Issuance – Looking Glass creates a Credential Offer, then surfaces each real wallet→issuer hop (Credential Issuer Metadata, Token Request, Nonce Request, proof JWT, Credential Request/Response) from the wallet’s protocol transcript. The default credential is the ISO/IEC 18013-5 mobile driving licence (mso_mdoc). Format and HAIP are independent selectors. HAIP issues with client and key attestation; the hosted wallet supplies that material. A 400 on import without attestation env is the real HAIP gate on a self-hosted wallet.
  • Pre-Authorized + tx_code – Same glass-box stages with mandatory tx_code on the token request
  • Deferred Issuance Polling – Same stages plus Deferred Credential Poll until the credential is ready
  • Issuer-Initiated Offer – Build an authorization_code offer with issuer_state. Under Advanced, paste an optional wallet Credential Offer Endpoint; the issuer then delivers the offer with a real HTTPS GET (credential_offer query parameter; 2xx/3xx accepted without following redirects). Otherwise Looking Glass exposes an openid-credential-offer:// invocation URI/QR. Looking Glass then reuses the OID4VP wallet-handoff chrome (awaiting_user + “Check Result”): each click checks a separate opaque status_uri once for whether the issuer has received the wallet’s validated PAR, issued the access token, and issued the credential, or reports the offer context has expired. Looking Glass does not complete PAR/token/credential in-browser. When the wallet PAR uses scope only (for example vc:mdl), the issuer binds authorization to the intersection with the offer’s credential configurations.
Property Value
Credential configuration selection Wallet chooses from credential_configurations_supported (dc+sd-jwt / mso_mdoc). The default and lead entry is the mDL (MobileDrivingLicenceMsoMdoc); omitting an explicit configuration selects it.
Credential formats Advertised: mso_mdoc (ISO/IEC 18013-5 mDL, default), dc+sd-jwt (SD-JWT VC). Also issuable by id: jwt_vc_json, jwt_vc_json-ld, ldp_vc (W3C Data Integrity).
Proof container OID4VCI 1.0 proofs object keyed by proof type (for example, {"jwt":["<compact-jws>", "..."]}). Issuer metadata advertises batch_credential_issuance.batch_size (20); each JWT in the array receives one credential copy of the same Credential Dataset bound to that proof key. Other proof types are not advertised; the removed draft proof parameter is ignored.
mso_mdoc device key binding Proof JOSE header jwk must be an EC P-256 key; it is bound into the MSO deviceKeyInfo.deviceKey as a COSE_Key. Issuer metadata advertises the credential signing algorithm as COSE identifier -7, while the JWT proof algorithm remains JOSE ES256. Issued mDLs include the mandatory portrait as a valid, identity-derived stylized JPEG rather than presenting it as a real person’s photograph.
Proof JWT header type openid4vci-proof+jwt
Proof JWT Protected header contains exactly one key reference (jwk is supported); payload requires aud, nonce, and iat. Anonymous pre-authorized clients omit iss.
Deferred issuance indicator transaction_id returned by credential endpoint (e.g., an opaque identifier such as "txn_...")

Pre-Authorized Code Issuance Flow (OID4VCI §4, §6.1, §7)

Section titled “Pre-Authorized Code Issuance Flow (OID4VCI §4, §6.1, §7)”
  1. Resolve Credential Offer — Wallet receives credential_offer_uri out-of-band and fetches the credential offer object from the Credential Issuer. Offer contains credential_issuer, credential_configuration_ids, and grants.urn:ietf:params:oauth:grant-type:pre-authorized_code.
  2. Discover Issuer Metadata — Wallet fetches Credential Issuer Metadata via OID4VCI §12.2.2 well-known insertion (https://host/.well-known/openid-credential-issuer{path}), preserving a trailing slash on the issuer path when present.
  3. Token Request — Wallet calls token endpoint with grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code, pre-authorized_code, and tx_code when required by the offer grant.
  4. Token Response and Nonce Request — Authorization Server returns access_token, token_type, and scope (if applicable). The Wallet then obtains c_nonce from the advertised Nonce Endpoint.
  5. Credential Request — Wallet submits credential_configuration_id and proofs: { "jwt": ["...", ...] } with one or more proof JWTs (up to batch_credential_issuance.batch_size) bound to the active c_nonce and the credential issuer audience. Multiple proofs request multiple copies of the same Credential Dataset, each bound to a distinct proof key.
  6. Credential Response — Issuer validates proof key binding, nonce freshness, and audience/subject constraints, then returns:
    • Immediate: a credentials array (one entry per accepted proof, never more than proofs submitted) and a notification_id, or
    • Deferred: transaction_id — wallet then polls deferred_credential until it receives the credentials array and notification_id.
  7. Notification — Wallet reports credential_accepted, credential_failure, or credential_deleted to the advertised notification_endpoint. The identifier is bound to the issuing access token and exact retries are idempotent.

In addition to pre-authorized_code, the token endpoint accepts grant_type=authorization_code. The Authorization Server (the mockidp-backed /oidc/authorize and /oid4vci/token) is discoverable via RFC 8414 metadata:

  1. Wallet reads authorization_servers from the Credential Issuer Metadata (defaults to the issuer’s own identifier).
  2. Wallet fetches {authorization_server}/.well-known/oauth-authorization-server (RFC 8414 Section 3.1 well-known URI insertion — for issuer https://host/oid4vci this resolves to https://host/.well-known/oauth-authorization-server/oid4vci; a trailing slash on the issuer path is stripped). The wallet does not fall back to openid-configuration for OID4VCI. Discovery yields authorization_endpoint, token_endpoint, grant_types_supported, code_challenge_methods_supported (S256), authorization_response_iss_parameter_supported (true, RFC 9207), and token_endpoint_auth_methods_supported. Scope-only authorization does not invent authorization_details in the Token Response; RAR requests still receive credential_identifiers for the requested configurations.
  3. HAIP wallets push the request to the advertised RFC 9126 pushed_authorization_request_endpoint with client attestation and PKCE S256. A PAR body must not include request_uri (RFC 9126 §2.1); the endpoint rejects it with HTTP 400 invalid_request. Authorization-code DPoP binding may be established via a DPoP proof, the dpop_jkt form parameter (RFC 9449 Section 10.1), or both when the JWK thumbprints agree; a mismatch is rejected with invalid_dpop_proof. Wallets may still introduce DPoP only at token redemption under the HAIP profile. The resulting request_uri carries the exact client, redirect, and credential configuration bindings into authorization. Token redemption by a different attested client returns HTTP 400 invalid_grant (OIDCC §3.1.3.4 / FAPI2 SP), including when that second client has not previously called PAR.
  4. Wallet drives the standard OAuth 2.0 authorization code flow against authorization_endpoint with only client_id and request_uri after PAR (FAPI2 SP Final §5.3.3.2), then exchanges the code at token_endpoint with the same client attestation authentication used at PAR, receiving access_token before requesting c_nonce from the Nonce Endpoint. PAR request_uri values stay usable across authorize page loads and are consumed only when authorization completes (FAPI 2.0 SP §5.3.2.2 Note 3); PAR-backed requests never silent-SSO via an existing session cookie and require an explicit Continue (or password login) after the preload visit.

This path is implemented at the issuer HTTP/API layer and by the hosted wallet reference client (protocolsoup-wallet: POST /api/import, GET /api/oid4vci/callback). Looking Glass pre-authorized executors create the issuer offer, then delegate redemption to the wallet harness via POST /api/import (token, proof, credential, and deferred polling run in the wallet — not in the browser). The issuer-initiated Looking Glass flow (oid4vci-issuer-initiated) creates and optionally delivers an authorization_code offer, then observes issuer-side PAR/token/credential milestones via status_uri; it does not impersonate the wallet. Pointing Looking Glass Advanced credential_offer_endpoint at the wallet (with HAIP attestation env configured when the offer targets a HAIP configuration) is how a wallet-driven authorization-code path is exercised.

Authorization requests can carry openid_credential authorization_details. The issuer validates each requested configuration, binds the approved details to the authorization code and access token, returns stable credential_identifiers in the token response, and authorizes credential requests by credential_identifier. A credential identifier that is unknown or not bound to the access token is rejected with unknown_credential_identifier (OID4VCI §8.3.1.2), including when the Token Response never returned credential_identifiers. A replayed authorization code returns invalid_grant and revokes the access token previously issued from that code so credential (and related) resource endpoints reject it with invalid_token (RFC 6749 Section 4.1.2).

The issuer implements the following HAIP-related issuance controls as opt-in, trust-anchor-gated (or otherwise explicitly enabled) API features. Named certified ProtocolSoup versions and OID4VCI profiles are listed on the trust page; this section describes the controls in the running demo, not a blanket claim for every deployment.

The hosted wallet reference client drives client attestation, key attestation, DPoP, and credential-response encryption together when HAIP attestation material is configured (WALLET_CLIENT_ATTESTATION_ATTESTER_JWK_JSON and WALLET_CLIENT_ATTESTATION_KEY_ATTESTATION_JWK_JSON, plus related WALLET_OID4VCI_ATTESTED_CLIENT_ID / WALLET_CLIENT_ATTESTATION_ISSUER / WALLET_KEY_ATTESTATION_* settings). Looking Glass pre-authorized executors delegate redemption to that wallet path via /api/import rather than minting attestation and DPoP proofs in the browser. Issuer-initiated Looking Glass observes a real wallet conversation via status_uri. The unattested Final flow remains unaffected by default.

  • Sender-constrained access tokens (RFC 9449 DPoP). The token endpoint accepts an optional DPoP proof header; when present, the issued access token is bound to the proof’s key via a cnf.jkt claim, with token_type: DPoP in the response instead of Bearer. The authorization_code grant also issues a refresh token; when Client Attestation was used that refresh token is bound to the Client Instance Key (OAuth2-ATCA §10.3) and not to the DPoP key (RFC 9449 §5 treats attestation like other client authentication). When DPoP was used without attestation, the refresh token is DPoP-bound. Refresh tokens are not rotated on use (FAPI2 SP Final §5.3.2.1-9); FAPI2 may still present a new DPoP proof key on refresh. Pre-authorized code grants do not issue refresh tokens. A DPoP-bound access token is then rejected at the credential, nonce, deferred_credential, and notification endpoints unless presented as Authorization: DPoP <token> with a matching, unreplayed DPoP proof — presenting it as a bare bearer token fails outright, never silently downgrades. A server-provided nonce challenge (DPoP-Nonce response header, use_dpop_nonce error) is available independently at the token endpoint (SHOWCASE_DPOP_NONCE_REQUIRED) and at the resource endpoints (SHOWCASE_DPOP_RESOURCE_NONCE_REQUIRED), off by default. This issuer’s own RFC 8414 metadata advertises dpop_signing_alg_values_supported for the accepted proof algorithms. Only JWT access tokens can be bound; opaque/reference token binding is out of scope.
  • Client attestation (OAuth 2.0 Attestation-Based Client Authentication). The PAR and token endpoints accept an OAuth-Client-Attestation + OAuth-Client-Attestation-PoP header pair as an alternative to client_secret/Basic auth. The attestation JWT’s x5c chain is verified against a configured trust anchor, its cnf.jwk becomes the Client Instance Key that must sign the PoP JWT, and the PoP’s jti is single-use. When both headers are present, they are the authentication verdict for the request — a failed attestation is never silently downgraded to a weaker method. The hosted wallet treats each HTTP attempt as a new authenticated request: on a DPoP use_dpop_nonce retry at PAR or token, it keeps the Client Attestation JWT and mints a fresh PoP with a new jti (OAuth2-ATCA single-use PoP jti). Credential and other resource requests similarly retry when the RS returns HTTP 401 (or 400) with use_dpop_nonce and a DPoP-Nonce header (RFC 9449). The attested sub becomes the authoritative client_id. Advertised in AS metadata’s token_endpoint_auth_methods_supported as attest_jwt_client_auth only once a trust anchor is configured, alongside ES256 in client_attestation_signing_alg_values_supported and client_attestation_pop_signing_alg_values_supported.
  • Key attestation (OID4VCI 1.0 Appendix D). Credential configurations may set key_attestations_required (advertised under proof_types_supported.jwt in Credential Issuer Metadata). For those configurations, the proof JWT’s JOSE header must carry a key_attestation JWT (typ: key-attestation+jwt) whose x5c chain is verified against a configured trust anchor, and whose attested_keys must include the proof’s JOSE-header jwk holder key and satisfy any required key_storage / user_authentication attack-potential levels. HAIP batch issuance from the hosted wallet puts every proof key in one Key Attestation JWT (HAIP 1.0 Section 4.5.1); each jwt proof repeats that JWT in its key_attestation header.
  • Encrypted credential responses. A wallet may include a credential_response_encryption object (jwk, enc) on the Credential or Deferred Credential Request. When present and valid, the response is returned as a compact JWE (Content-Type: application/jwt) — ECDH-ES key agreement to the wallet’s ephemeral key, then AES-GCM content encryption (A128GCM or A256GCM) — instead of plain JSON. Advertised in Credential Issuer Metadata’s credential_response_encryption object; encryption_required stays false.
  • Encrypted credential requests. Credential Issuer Metadata advertises an ECDH-ES public key plus A128GCM/A256GCM in credential_request_encryption. The Credential Endpoint and Deferred Credential Endpoint accept the compact JWE as application/jwt, decrypt it with the persistent key, and apply the same duplicate-member and proof validation as a JSON request.
  • Signed metadata and HAIP SD-JWT VC. Requesting Credential Issuer Metadata with Accept: application/jwt returns an ES256 JWT with typ: openidvci-issuer-metadata+jwt, top-level metadata claims, and a leaf-only x5c. The HAIP SD-JWT configuration uses the same persistent certificate-backed signer and assigns each credential a unique OAuth Status List index served as a signed statuslist+jwt. The OID4VP verifier fetches that list during presentation and checks it with the same issuer key already accepted for the credential. The HAIP SD-JWT configuration’s vct is an HTTPS URL on the issuer; that URL serves the matching SD-JWT VC Type Metadata document, while disclosed claims and _sd commitments use the SD-JWT VC top-level claim model rather than the legacy vc container.

These controls are exercised by backend regression tests and by the wallet reference client when HAIP attestation env is configured.

  • Credential offer envelope enforces XOR: exactly one of credential_offer or credential_offer_uri.
  • tx_code is required when the offer grant includes a tx_code object.
  • Proofs are required when proof types are declared for the credential configuration.
  • Proof JWT header typ must be openid4vci-proof+jwt.
  • Proof payload must include aud, nonce, and iat; anonymous clients omit iss. The proof key is carried in exactly one JOSE key-reference header, with jwk supported by this issuer.
  • Nonce is freshness-bound and one-time in issuance requests (replay and expiry are rejected).
  • Deferred polling requires matching transaction_id and same access token lineage.
  • Authorization codes and deferred transaction identifiers are redeemed atomically at most once, including under concurrent requests.
  • Notification retries are idempotent only for the same event/description; conflicting lifecycle events return a conflict instead of rewriting history.
  • Issued SD-JWT VC uses typ: dc+sd-jwt, canonical tilde serialization, a top-level _sd_alg, and positional recursive disclosure processing for objects and arrays. Reserved/permanent-claim collisions are rejected, and a stored issuer credential must not already contain a Key Binding JWT.
  • mso_mdoc verification requires a tagged MSO, version/digest/document/device invariants, unique element identifiers, DeviceResponse version 1.0, and an Annex B document-signer profile. Revocation requires external CRL/OCSP state and is not inferred from local certificate-path validation.
Endpoint Role Discovery Source Purpose
Credential Issuer Metadata Endpoint /.well-known/openid-credential-issuer/{issuer-path} Discover issuer metadata, credential configurations, and endpoint URLs
Authorization Server Metadata Endpoint /.well-known/oauth-authorization-server/{issuer-path} (RFC 8414) Discover authorization_endpoint, token_endpoint, jwks_uri, and supported grant/auth methods for authorization_code
Authorization Server JWKS jwks_uri from AS metadata (/oid4vci/.well-known/jwks.json) Verify JWT/SD-JWT credential signatures (RFC 8414 / RFC 7517)
Pushed Authorization Request Endpoint pushed_authorization_request_endpoint from AS metadata Validate and bind a short-lived HAIP authorization request
Credential Offer URI credential_offer_uri Retrieve a credential offer by reference
Token Endpoint Issuer/Authorization Server metadata Exchange pre-authorized_code or authorization_code for an access token; accepts client attestation headers
Nonce Endpoint nonce_endpoint from issuer metadata Public endpoint that returns a fresh, single-use c_nonce; no access token is required
Credential Endpoint credential_endpoint from issuer metadata Submit proof and request credential issuance
Deferred Credential Endpoint deferred_credential_endpoint from issuer metadata Poll a pending issuance transaction with transaction_id
Status List Endpoint Credential status.status_list.uri Retrieve the certificate-backed signed OAuth Status List Token
  • Pre-authorized token request body (form-encoded): grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code&pre-authorized_code=...
  • Proof JWT protected header for the default mDL mso_mdoc: {"typ":"openid4vci-proof+jwt","alg":"ES256","jwk":{...}}, where the EC P-256 public key is the device key bound into the MSO.
  • Deferred credential response shape: {"transaction_id":"...","c_nonce":"...","c_nonce_expires_in":...}

Default credential format (behavioural change)

Section titled “Default credential format (behavioural change)”

As of the parity-then-flip change, the issuer’s default and lead credential configuration is the ISO/IEC 18013-5 mobile driving licence (mso_mdoc, configuration ID MobileDrivingLicenceMsoMdoc). This was previously the SD-JWT VC university-degree configuration.

What changed: a request that omits an explicit credential configuration (an offer with no credential_configuration_ids, or a credential request that relies on the issuer default) now receives the mDL mso_mdoc instead of the SD-JWT VC.

What did not change: explicit format selection behaves exactly as before. SD-JWT VC and the W3C formats remain fully supported and selectable by naming their configuration:

  • SD-JWT VC: credential_configuration_ids: ["UniversityDegreeCredential"]
  • JWT VC JSON: credential_configuration_ids: ["UniversityDegreeCredentialJWT"]
  • JWT VC JSON-LD: credential_configuration_ids: ["UniversityDegreeCredentialJWTLD"]
  • LDP VC: credential_configuration_ids: ["UniversityDegreeCredentialLDP"]

Action for consumers: API consumers that relied on the implicit SD-JWT VC default must now name UniversityDegreeCredential (or the desired configuration) explicitly to continue receiving SD-JWT VC. The wallet harness honours the WALLET_DEFAULT_CREDENTIAL_FORMAT environment variable (default mso_mdoc) for its stored and presented format.

Note that the mDL proof uses an EC P-256 device key signed with ES256: the proof header jwk is bound into the MSO deviceKeyInfo.deviceKey as a COSE_Key, per ISO/IEC 18013-5.

The Looking Glass credential inspector treats every registered credential format — including mso_mdoc, the default and lead configuration — as a first-class surface; there is no per-format branch in the UI. After the credential endpoint returns an issued credential, the Looking Glass flow calls POST /lookingglass/decode/credential to decode it through the same shared CredentialFormat registry and evidence shape that OID4VP presentation verification already produces, rather than a JWT-only decoder that would treat mso_mdoc’s CBOR encoding as opaque.

The response keeps two things structurally separate, because they must never be rendered as if they were the same claim:

  • Evidence - the credential’s own claims (namespace-nested for mso_mdoc), plus a selective-disclosure summary naming its mechanism (mso_valuedigests for mso_mdoc, sd_jwt_disclosures for dc+sd-jwt), a committed-element count, and whether that count is exact. The mso_mdoc MSO valueDigests count is exact — ISO/IEC 18013-5 has no decoy-digest mechanism. An SD-JWT _sd digest count is reported as an upper bound only, because the spec permits decoy digests specifically so a verifier cannot infer the true claim count from it. This replaces an earlier disclosure count that used SD-JWT tilde-counting semantics for every format, which was always zero for mso_mdoc regardless of how many elements the MSO actually committed to.
  • Assurance - what has actually been checked, never collapsed into a single validity flag. This endpoint supplies no issuer keys or IACA trust anchors, so issuer_trust reads not_evaluated for every format — distinct from failed, which means a signature check ran against real trust material and did not succeed. For mso_mdoc only, digests_consistent_with_mso independently recomputes each disclosed IssuerSignedItem’s digest against the credential’s own MSO valueDigests. That proves internal consistency between the items and the MSO, not authenticity of the MSO itself — an unauthenticated MSO with matching digests is trivially forgeable.

Both fields are part of the shared, format-agnostic evidence/assurance shape rather than mso_mdoc-specific additions: the same selective-disclosure summary and assurance envelope render for dc+sd-jwt, jwt_vc_json, jwt_vc_json-ld, and ldp_vc, with the fields that do not apply to a given format simply absent rather than zeroed.

  • Credential offer envelope: exactly one of credential_offer or credential_offer_uri
  • Metadata retrieval: well-known endpoint resolves to issuer metadata and endpoint URLs
  • Token exchange: pre-authorized_code grant processing and tx_code enforcement when offered
  • Proof JWT: typ=openid4vci-proof+jwt, JOSE-header jwk key binding, aud matches issuer, nonce is a live single-use c_nonce
  • Nonce behavior: replayed or expired c_nonce is rejected; next nonce is returned after successful issuance
  • Credential response: serialization matches negotiated format (e.g. dc+sd-jwt includes issuer-signed JWT + disclosures, ldp_vc includes Data Integrity proof, mso_mdoc is base64url-encoded CBOR IssuerSigned with a COSE_Sign1 IssuerAuth)
  • Credential time claims (iat/nbf/exp, and mdoc MSO validityInfo) are rounded to the start of the UTC day (RFC 9901 §10.1) so same-dataset credentials are not linkable by precise issuance timestamps
  • Deferred flow: issuance_pending before ready time, then final credential on successful poll
  • Authorization Server metadata: authorization_endpoint/token_endpoint resolve and accept the discovered authorization_code grant
  • Client attestation (when configured): a request presenting OAuth-Client-Attestation/-PoP is authenticated by that mechanism, not silently downgraded on failure; replayed PoP jti is rejected
  • Key attestation (when a configuration requires it): credential requests without a valid key_attestation proof header are rejected with invalid_proof
  • Encrypted responses (when requested): credential_response_encryption is validated against advertised alg/enc values and the response is returned as a JWE
  • Credential inspection: POST /lookingglass/decode/credential on an issued mso_mdoc reports a non-zero, exact committed_count and issuer_trust: not_evaluated (never failed, since no trust anchor was supplied); the same call on dc+sd-jwt reports committed_count_is_exact: false