Skip to content
Registry StackDocsLatest

OID4VCI wallet interop

View as Markdown

This guide describes the implemented OpenID4VCI wallet facade for Registry Notary adopters. It focuses on what wallet and platform teams need to configure and test.

Use the OID4VCI facade when a citizen wallet should request a Registry Notary SD-JWT VC directly. The wallet holds an access token from an authorization server, proves control of a holder key, and receives a short-lived credential for a configured claim.

The facade is intentionally narrow:

  • Credential format is dc+sd-jwt.
  • Issued VC media type is application/dc+sd-jwt.
  • Proof type is JWT.
  • Supported proof algorithm is EdDSA.
  • Supported holder binding method is did:jwk.
  • Issuance is backed by direct self-attestation policy and configured evidence claims.
  • Delegated attestation transaction tokens are rejected. Delegated wallet issuance is not part of this OID4VCI facade version.

It is not a full OpenID4VCI issuer product. It is an interoperability facade for Registry Notary’s current SD-JWT VC issuance path.

The wallet facade requires server-side OID4VCI and self-attestation configuration before any wallet can request a credential. Self-attestation is the policy gate that prevents a wallet from using any valid token to request another person’s credential. The operator who runs Notary owns these settings; this guide assumes they are already in place.

For the full configuration, including the auth.oidc, self_attestation, and oid4vci blocks and their constraints, see the operator configuration reference. For the policy gate that binds a request to the token subject, see the self-attestation operator guide.

For a public holder wallet, the normal human-facing entry point is:

GET /oid4vci/offer/start?credential_configuration_id=<id>

Open that URL in the citizen browser, complete the configured identity-provider login, then use the rendered offer page:

  • scan or paste the openid-credential-offer:// URI into the wallet;
  • enter the displayed numeric PIN when the wallet asks for the tx_code;
  • let the wallet redeem the offer at the Notary token_endpoint;
  • confirm the wallet stores a dc+sd-jwt credential.

A successful wallet run leaves these externally visible facts:

  • issuer metadata is reachable at /.well-known/openid-credential-issuer;
  • the configured Type Metadata is reachable at /.well-known/vct/{vct_path} without authentication;
  • issuer metadata advertises token_endpoint only when the authenticated pre-authorized-code flow is enabled;
  • the offer contains the urn:ietf:params:oauth:grant-type:pre-authorized_code grant and a tx_code object;
  • the issued SD-JWT VC payload has vct equal to the requested configuration, iss equal to the Notary issuer DID, and holder binding in cnf.jwk / cnf.kid for the wallet’s did:jwk;
  • the only selectively disclosable claim disclosures are represented through _sd and _sd_alg: "sha-256" in the payload.
sequenceDiagram
    autonumber
    actor Citizen
    participant Browser
    participant Notary as Registry Notary
    participant IdP as Identity provider
    participant Wallet

    Citizen->>Browser: Open /oid4vci/offer/start
    Browser->>Notary: Start offer for credential configuration
    Notary-->>Browser: Redirect to identity provider
    Browser->>IdP: Authenticate citizen
    IdP-->>Browser: Redirect to /oid4vci/offer/callback
    Browser->>Notary: Send code and state
    Notary->>IdP: Exchange code and validate subject
    Notary-->>Browser: Render offer URI plus PIN
    Citizen->>Wallet: Scan or paste offer, enter PIN
    Wallet->>Notary: POST /oid4vci/token with code plus tx_code
    Notary-->>Wallet: Access token plus c_nonce
    Wallet->>Notary: POST /oid4vci/credential with holder proof
    Notary-->>Wallet: Holder-bound dc+sd-jwt credential

The current wallet-facing flow is:

  1. Wallet discovers issuer metadata.
  2. Wallet obtains or receives a credential offer for a configured credential.
  3. Wallet obtains an OIDC access token from the configured authorization server.
  4. Wallet requests a nonce when nonce support is enabled.
  5. Wallet sends a credential request with format: "dc+sd-jwt" and a JWT proof.
  6. Notary validates the access token, subject binding, self-attestation policy, nonce and proof, then reads the source and issues the SD-JWT VC.

The credential request should not carry a raw subject id as a free-form wallet choice. The subject comes from the OIDC token claim configured in self_attestation.subject_binding and must match the Notary request context. If the access token’s scoped authorization details select access_mode: delegated_attestation, the credential endpoint rejects the request rather than issuing for a dependent target.

A public holder wallet (for example walt.id wallet-api) is a PKCE client and cannot authenticate to a confidential authorization server such as eSignet (an OpenID Connect identity service). For those wallets the Notary additionally supports an authenticated pre-authorized-code flow. The citizen still authenticates at eSignet; the wallet never authenticates to eSignet and only ever talks to the Notary.

This flow is disabled by default. When oid4vci.pre_authorized_code.enabled is false (or unset), this flow’s three endpoints (/oid4vci/offer/start, /oid4vci/offer/callback, and /oid4vci/token) return 404, issuer metadata advertises no token_endpoint, and credential offers stay authorization_code. The confidential-client authorization_code path described in Wallet flow is unchanged regardless of this setting.

When enabled, the flow is:

  1. The citizen browser opens GET /oid4vci/offer/start, optionally with ?credential_configuration_id=<id>. This endpoint is public and unauthenticated. It begins the eSignet authorization-code login as the configured confidential RP (PKCE S256), stores short-lived state, and 302-redirects the browser to eSignet. It mints no pre-authorized_code and no credential material.

  2. The citizen authenticates at eSignet. eSignet redirects back to GET /oid4vci/offer/callback?code=...&state=... (public, unauthenticated). The Notary exchanges the code with eSignet using private_key_jwt, validates the returned id_token, captures the exact self_attestation.subject_binding claim value (the civil id), and only then mints one single-use pre-authorized_code. By default it also mints one numeric tx_code (a PIN). It renders an HTML offer page carrying the openid-credential-offer:// URI (the QR payload) and, when enabled, the PIN shown out-of-band from the QR.

  3. The wallet reads the offer. Its grants carry urn:ietf:params:oauth:grant-type:pre-authorized_code with the pre-authorized_code. When the offer includes a tx_code object, the citizen enters the PIN.

  4. The wallet redeems the offer at POST /oid4vci/token (public, unauthenticated). A tx_code is required when the offer includes a tx_code object; the token endpoint rejects a missing or wrong PIN in that mode. The request is form-encoded or JSON:

    Terminal window
    curl -fsS -X POST https://notary.example.gov/oid4vci/token \
    -H 'content-type: application/x-www-form-urlencoded' \
    --data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code' \
    --data-urlencode "pre-authorized_code=$CODE" \
    --data-urlencode "tx_code=$PIN"

    The response carries a short-TTL Notary-signed access_token, token_type, expires_in, and a c_nonce.

  5. The wallet calls POST /oid4vci/credential with the Notary-issued access_token and a did:jwk holder proof, exactly as in the Wallet flow credential request step. The issued SD-JWT VC is bound to the eSignet-authenticated subject (the civil id determines whose claim is evaluated) and to the holder’s did:jwk key.

The browser callback URL itself is not the wallet input. The callback renders an HTML offer page after identity-provider authentication succeeds; the wallet receives the openid-credential-offer:// URI from that page and the citizen enters the displayed PIN separately.

The pre-authorized_code is single-use and short-lived: a second redemption fails, and the code expires after pre_authorized_code_ttl_seconds. When tx_code.required is true (the default), repeated wrong-PIN attempts on one code hit self_attestation.rate_limits.tx_code_attempts_per_code_per_minute and lock the code. A flood of random codes from one client address is throttled by the existing per-address invalid-attempt limiter.

Operators may set oid4vci.pre_authorized_code.tx_code.required: false for wallets that cannot present a transaction code. Registry Notary reports this as bearer_offer mode in the admin posture document. In this mode, the pre-authorized code is bearer credential material until it is redeemed, so validation requires pre_authorized_code_ttl_seconds <= 300. Use it only for controlled demos or compatibility deployments where the wallet cannot send a tx_code.

This bearer-offer window is separate from the issued VC lifetime. Wallet-held credentials use the requested credential profile’s validity_seconds, so demo profiles can issue credentials valid for days while keeping offer codes and Notary access tokens short-lived.

The Notary mints its access token with a dedicated signing key separate from the SD-JWT VC credential key, with its own issuer, audience, and a distinct header typ. It is verified by a second, separately-keyed trust anchor: an eSignet token and a Notary token each pass only their own verifier.

During governed key rotation, keep the outgoing access-token key as publish_only and list it in auth.access_token_signing.verification_key_ids until existing Notary access tokens and pre-authorized codes have expired. The active signing_key_id signs new codes and access tokens; verification_key_ids only accepts older public keys for verification.

Issuer metadata is derived from oid4vci and the configured credential configurations. Wallets should verify that metadata advertises:

  • credential_issuer equal to the public issuer URL.
  • Authorization servers matching the wallet’s token issuer.
  • Credential endpoint matching the configured HTTPS endpoint.
  • Credential configurations for the expected credential ids.
  • format: dc+sd-jwt.
  • proof_signing_alg_values_supported: [EdDSA].
  • cryptographic_binding_methods_supported: [did:jwk].
  • vct equal to a public HTTPS URL served by the Notary.
  • token_endpoint equal to the Notary’s /oid4vci/token endpoint, present only when the authenticated pre-authorized-code flow is enabled. Its presence is the metadata signal that the Notary is its own authorization server for the pre-authorized-code grant; the grant itself is advertised per-offer in the credential offer’s grants. When the flow is disabled there is no token_endpoint.

For SD-JWT VC wallet interoperability, the Notary serves public Type Metadata for each configured vct. Per the SD-JWT VC Type Metadata convention, a consumer dereferences an HTTPS vct by inserting /.well-known/vct between the host and the path: for vct = https://{host}/credentials/citizen-civil-status/v1 it fetches https://{host}/.well-known/vct/credentials/citizen-civil-status/v1. walt.id (wallet-api 0.20.2) does exactly this during offer setup and aborts the flow if it does not get a 200. The Notary serves the metadata at that well-known location, GET /.well-known/vct/{vct_path}, and a wallet can fetch it without authentication. The route uses a trailing-wildcard capture, so nested configured paths such as /.well-known/vct/credentials/dhis2/health-status/v1 resolve. The bare GET /credentials/{vct_path} route is also served for spec-compliant consumers that dereference the vct directly, but it is not the path walt.id fetches. The response is application/json, returns 404 when OID4VCI is disabled or no configured vct matches, and includes:

  • vct: the configured vct identifier (https://{host}/{vct_path}), not the requested well-known URL.
  • name and display[].locale/display[].name.
  • claims[].path using the configured OID4VCI claim_id.
  • claims[].display[].locale/claims[].display[].label.
  • claims[].sd: "always", because Notary emits evaluated claim results as selectively disclosable SD-JWT disclosures.
  • claims[].registry_notary_semantics when the underlying claim config declares semantic bindings such as PublicSchema concept, property, vocabulary, predicate, or derived_from terms.

Browser-based wallets from configured self-attestation wallet origins receive CORS headers on the /.well-known/vct/... metadata surface (the response echoes the request Origin in access-control-allow-origin, and OPTIONS preflights for an allowed method return 204 with access-control-allow-origin and access-control-allow-methods).

Credential offers are intentionally lightweight. They tell the wallet which credential configuration to request and which issuer metadata to use. If more than one credential configuration is enabled, wallet tests should explicitly select the intended configuration.

Enable nonce support for real wallet interop:

oid4vci:
nonce:
enabled: true
ttl_seconds: 300
nonce_endpoint: https://notary.example.gov/oid4vci/nonce

Nonce TTL must be between 1 and 600 seconds. When nonce is enabled, nonce_endpoint is required.

For multiple credential configurations, the nonce request should identify the credential configuration. That keeps a nonce from being reused across a different credential configuration.

Use Redis replay storage for nonce-backed wallet traffic when more than one process can receive requests.

The wallet credential request can use the legacy single-proof shape:

{
"format": "dc+sd-jwt",
"credential_configuration_id": "birth_record_sd_jwt",
"proof": {
"proof_type": "jwt",
"jwt": "<holder-proof-jwt>"
}
}

OID4VCI 1.0 wallets can instead send the final-spec proof array shape:

{
"credential_configuration_id": "birth_record_sd_jwt",
"proofs": {
"jwt": ["<holder-proof-jwt>"]
}
}

Send either proof or proofs, not both. Notary accepts one JWT proof for a credential request and rejects requests with multiple proof JWTs.

The proof JWT should demonstrate holder control of a did:jwk key and be fresh within oid4vci.proof.max_age_seconds, allowing only max_clock_skew_seconds of clock difference.

Notary rejects unsupported formats, unsupported proof algorithms, stale proofs, replayed nonces, subject-binding mismatches, claims outside the allow-list, and credential profiles outside the allow-list.

Successful responses contain the issued SD-JWT VC:

{
"format": "dc+sd-jwt",
"credential": "<sd-jwt-vc>",
"credentials": [
{
"credential": "<sd-jwt-vc>"
}
],
"c_nonce": "<optional-next-nonce>",
"c_nonce_expires_in": 300
}

Wallets should store the credential as SD-JWT VC and verify:

  • Issuer key resolves from Notary JWKS.
  • vct matches the requested credential configuration.
  • Holder binding is the wallet’s did:jwk.
  • Expiry is short and within deployment policy.
  • Optional status URL is handled according to verifier policy.

The response does not need to echo every request field. Wallet tests should assert the credential content, not only the response envelope.

For each wallet product or SDK:

  • Can it parse issuer metadata with dc+sd-jwt credential configurations?
  • Can it request or accept a credential offer for a specific configuration id?
  • Can it obtain an access token with the configured audience and scopes?
  • Does the access token carry the subject-binding claim Notary expects?
  • For public wallets, can it redeem pre-authorized_code offers with a numeric tx_code at the Notary token_endpoint?
  • Can it generate a JWT proof using EdDSA and a did:jwk holder key?
  • Does it include and refresh nonces according to issuer responses?
  • Does it accept short-lived credentials?
  • Does it preserve SD-JWT disclosures without logging them?
  • Can it display status-free credentials and status-bearing credentials?
  • Does it surface a distinct, actionable error for invalid_token, proof failure, nonce replay, and subject mismatch?

Record the wallet name, version, supported draft/profile behavior, and any configuration overrides in your deployment notes.

  • Notary validates token and policy before source reads.
  • Subject binding is exact; do not use normalization that could join different civil identifiers.
  • A holder DID can become a correlation handle if reused widely. Wallets should follow their privacy model for pairwise or purpose-specific keys.

For wallet-origin restrictions, secret handling, rate-limit layering, and logging boundaries, see the deployment hardening runbook.

SymptomLikely causeCheck
Metadata route is unavailableoid4vci.enabled is false or self-attestation is disabledExpanded config and startup logs
Config fails validationOID4VCI references a claim or credential profile outside self-attestation allow-listscredential_configurations, self_attestation.allowed_claims, self_attestation.credential_profiles
Delegated token is rejectedThe credential endpoint only accepts direct self-attestation access tokensToken authorization details, self_attestation.delegation
Wallet token rejectedAudience, issuer, client id, scope, or algorithm mismatchauth.oidc, oid4vci.accepted_token_audiences, wallet token header and claims
Wallet never asks for PINOffer is still authorization_code, pre-authorized-code flow is disabled, or wallet ignored the grantIssuer metadata token_endpoint, offer grants, oid4vci.pre_authorized_code.enabled
PIN is rejectedWrong tx_code, expired code, reused code, or rate-limit lockoutOffer age, token endpoint response, tx_code_attempts_per_code_per_minute
Wallet aborts before login or PINWallet cannot resolve SD-JWT VC Type MetadataGET /.well-known/vct/{vct_path} returns 200 JSON without auth
Subject mismatchToken claim does not exactly match the requested subject contextself_attestation.subject_binding and identity-provider claims
Nonce rejectedNonce expired, reused, or from another configurationNonce TTL, replay store, credential configuration id
Proof rejectedUnsupported alg, wrong holder binding, stale proof, or clock skewWallet proof JWT and oid4vci.proof
Credential issued but wallet cannot verifyJWKS, issuer DID, kid, or vct mismatchSigning key config and credential profile
Works with one process but fails in active-activeIn-memory replay storeUse Redis replay storage