Released docs. You are viewing the documentation published with v0.46.0. Development docs are available at Latest.
If you run Registry Evidence and a wallet must receive its holder-bound
SD-JWT VC responses over
OID4VCI, run rstack evidence oid4vci serve beside it. The
wallet-delivery service speaks the protocol Registry Evidence does not, and turns each response
into the credential the wallet stores.
It never signs, never holds a holder private key, and adds no Evidence semantics, so
how Evidence works still describes every decision.
What you need
Section titled “What you need”- A definition with
subjectBinding: holder-boundthat permitssd-jwt-vc, as Enable SD-JWT VC sets up. The front end offers only holder-bound definitions. - A running Registry Evidence, as Build and deploy an Evidence project leaves you.
- A TLS proxy that publishes the front end’s public HTTPS origin.
- The Evidence component selected by
rstack, installed through Install Registry Stack.rstack evidence oid4vciselects its matching managed wallet-delivery helper. You can instead use the imageghcr.io/registrystack/evidence-oid4vci:<tag>from the latest release. The image runs as the distrolessnonrootuser (uid 65532), exposes port 8090, and runsserve --runtime-config /etc/registry-evidence-oid4vci/runtime.yaml. Mount the runtime file and the token client and audit hash keys read-only. Mount persistent writable storage at/var/lib/registry-evidence-oid4vcifor the adapter’s audit journal. The image has no shell, so your platform owns the probes.
Write the runtime file
Section titled “Write the runtime file”The runtime file names the front end’s public origin, the Registry Evidence it fronts, the token client it calls that gateway with, and who may create a credential offer. Start from this one and replace every origin with your own:
# yaml-language-server: $schema=https://id.registrystack.org/schemas/evidence/oid4vci-runtime/oid4vci-runtime.v1alpha1.schema.jsonapiVersion: id.registrystack.org/formats/evidence/oid4vci-runtime/v1alpha1kind: EvidenceOid4vciRuntimeConfigcredentialIssuer: https://wallet.example.orglistener: bind: 127.0.0.1:8090secretProviders: file: root: /run/secrets/evidence-oid4vciaudit: destination: file path: /var/lib/registry-evidence-oid4vci/audit.jsonl hashKeyRef: secret:file/audit-hash-keyevidence: baseUrl: https://evidence.example.orgtokenClient: tokenEndpoint: https://mint.example.org/token clientId: evidence-oid4vci privateKeyRef: secret:file/delivery-client.jwk.json resource: https://evidence.example.orgoffers: issuer: https://mint.example.org jwksUri: https://mint.example.org/.well-known/jwks.json audiences: ["https://wallet.example.org"] allowedClients: [adopter-front-end] requiredScopes: [oid4vci:offer]store: maximumOffers: 4096credentialIssuer is the public origin your TLS proxy publishes, which wallets see.
listener.bind is the private address the proxy forwards to: keep a loopback or private address
on a non-zero port such as 8090, not the public origin’s 443. The listener speaks plain HTTP.
privateKeyRef and audit.hashKeyRef follow the secret reference grammar.
Both key files must be regular files owned by the service user, mode 0400 or 0600, with a
single link. The audit key produces stable pseudonyms for the authenticated offer client, subject,
or both without storing their raw identities. Preserve it for as long as those records must remain
correlatable. A file destination is the durable journal; use stdout only when the deployment
collector durably accepts and preserves every line. Every key has the bounds and default its
schema gives it, and check also enforces the rules a schema cannot hold:
store.nonceLifetimeSecondsmust not exceedstore.accessTokenLifetimeSeconds.- The listener port must be non-zero.
metricsListener.bindmust be a loopback or private address on a non-zero port the delivery listener does not use.- A reference must name a provider the file enables, and
secretProviders.file.rootmust be absolute.
Check offline
Section titled “Check offline”Check the file before the service reads it:
rstack evidence oid4vci check --runtime-config runtime.yaml0 errors, 0 warnings in 1 filecheck reads that one file and nothing else, so it does not prove that either key resolves, that
the client key parses, or that the audit destination can open. inspect resolves and parses the
client key to read Registry Evidence metadata, but it does not read the audit key or open the audit
destination. serve resolves both keys, parses the client key, and opens the audit destination
when it starts. See
evidence-oid4vci check.
Inspect what wallets will read
Section titled “Inspect what wallets will read”With the client key in place, inspect reads the Registry Evidence’s definitions and prints the
metadata the service would publish. It can run beside the serving process because it does not open
or lock the audit destination:
rstack evidence oid4vci inspect --runtime-config runtime.yamlIt prints one JSON document: the profile marker registry.evidence.oid4vci-profile/v1, the
credential issuer and authorization server metadata, and a deployment block stating one replica,
that a restart invalidates outstanding exchanges, and whether metrics are enabled. If the gateway
cannot be reached, it exits 1 and logs that the credential source is unavailable. The wire behavior
follows the
frozen OID4VCI profile,
which also lists what the front end does not support.
Render the HTTP contract your own front end and the wallet will call, then start the service:
rstack evidence oid4vci openapi --output evidence-oid4vci.openapi.jsonrstack evidence oid4vci serve --runtime-config runtime.yamlYour front end creates a credential offer at /offers with an access token the offers block
admits; the wallet calls the two /.well-known metadata routes, /token, /nonce, and
/credential. The wallet delivery API reference renders
the same document. /health answers {"status":"ok"} while the process runs, and /ready
answers {"status":"ready"} while the required local audit destination can accept entries. It
answers 503 after that destination stops or stalls. Readiness does not depend on the state of the
gateway or token issuer.
Run exactly one replica: offers, codes, and tokens live in process memory, and a restart
invalidates every outstanding exchange. An independent process has a different tagging key and no
matching live state, so it refuses another process’s codes and tokens as unknown. One replica keeps
every later request routed to the process that created the exchange. A change to the gateway’s
bundle or batch ceiling needs a coordinated restart: stop evidence-oid4vci, change or restart the
gateway, then start evidence-oid4vci again so its first catalog comes from the new gateway.
Limits and refusals
Section titled “Limits and refusals”The service bounds what a caller can make it do. Every response carries the shared security
headers, and a request body over listener.maximumRequestBytes (16384 bytes by default, 1024 to
1048576) is refused before it is parsed. A request the web framework refuses, for a malformed body,
an oversize body, or an unsupported content type, is answered in the service’s
{"error", "error_description"} JSON shape and not in plain text.
The optional rateLimits block sets request budgets as token buckets. A runtime file without it is
valid and takes the defaults below. A request over its budget is answered 429 with a Retry-After
header and {"error": "temporarily_unavailable"}.
| Key | Default | What it budgets |
|---|---|---|
offersPerPrincipalPerMinute | 600 | Offers one authorized principal (the token’s client, else its subject) may create per minute |
offerBurst | 60 | The most offers one principal may create in a burst |
walletRequestsPerMinute | 3000 | Requests per minute each of the token, nonce, and credential endpoints takes from all wallets together |
walletRequestBurst | 300 | The most requests each of those endpoints admits in a burst |
The token, nonce, and credential endpoints each have one budget shared by every wallet, so a busy
deployment spends it quickly. These budgets use one key for all wallets, so they bound the service as
a whole and cannot tell one client from another: a single source can spend a shared budget and lock
every wallet out of that endpoint until it refills. Rate limit per source address at your ingress in
front of those three paths as well. Check that the defaults suit your wallet traffic before you
upgrade a busy deployment, and raise them in rateLimits rather than behind the proxy alone.
Metrics
Section titled “Metrics”Set metricsListener.bind to serve Prometheus text at /metrics on a private address:
| Metric | What it counts |
|---|---|
evidence_oid4vci_http_requests_total | Delivery requests by closed outcome class |
evidence_oid4vci_http_request_duration_seconds | Delivery request latency |
evidence_oid4vci_outcomes_total | Protocol and upstream outcomes |
evidence_oid4vci_store_entries | Entries in the fullest bounded offer, ledger, or token keyspace |
evidence_oid4vci_store_capacity | Refusal threshold for each bounded keyspace |
evidence_oid4vci_cleanup_expired_total | Expired in-memory entries released by cleanup |
What a wallet proof must carry
Section titled “What a wallet proof must carry”The /credential request carries one jwt proof for each holder key. The service checks, in this
order, a bare openid4vci-proof+jwt header typ, one key nomination (an inline public jwk or a
local did:jwk kid), an ES256 alg that agrees with the key, the signature, and then the
payload claims it uses: aud must be the credential issuer identifier as one string, iat must be
fresh, nonce must be the current challenge, and an optional exp must not have passed. iss
must be absent, because a pre-authorized code flow has no authenticated client.
Every other payload claim is ignored, as RFC 7519 section 4 directs for claims an implementation
does not understand, so a wallet library that adds jti still receives its credential. Header
parameters stay a closed set.
A refused proof answers invalid_proof with an error_description that names the failed rule, for
example “the proof was not accepted: required claim nonce is missing”. The same wording goes to
the service log. Neither repeats a nonce, a key, or any value the proof carried.
Check wallet interoperability
Section titled “Check wallet interoperability”Each release is checked against one wallet: Inji Wallet, at the exact revisions of the wallet and
its Kotlin and Swift OID4VCI clients that the
pinned profile
names. The check drives the release’s evidence-oid4vci binary through the pre-authorized code
flow of OID4VCI 1.0 Final with a did:jwk holder key and an ES256 proof, and verifies that the
dc+sd-jwt credential it returns is holder-bound Evidence. It also replays twelve refusals, among
them a wrong proof audience, a reused or expired nonce, and a replayed access token. The profile
records those wire shapes and cases, plus what the check excludes: device or UI testing,
authorization code with PAR and DPoP, deferred or encrypted issuance, and more than one replica.
The
receipt
records the run against those upstream revisions on 2026-08-09: the environment, the client tests
that passed, and the claim boundary. A pass is bounded evidence for those revisions, not
certification or general Inji compatibility. The
check scripts
live in the repository for contributors.
To confirm your own deployment, have your front end create a credential offer at /offers for a
holder-bound definition and open it in the wallet your holders use. The wallet reads the metadata,
redeems the pre-authorized code at /token, fetches a nonce, and calls /credential; the
credential it stores is the Registry Evidence’s SD-JWT VC response, bound to the wallet’s key.
Troubleshooting
Section titled “Troubleshooting”| Code | Meaning | Recovery |
|---|---|---|
platform.runtime-config.unavailable | The runtime file could not be read. | Check that the file exists and that the runtime user can read it. |
evidence.oid4vci.https-required | An origin is not https in strict mode. | Write an https URL, or set validationMode: supervised-local-development for a loopback process group. |
evidence.oid4vci.listener-port | The listener port is zero. | Bind the listener to a non-zero private port your TLS proxy forwards to. |
evidence.oid4vci.nonce-lifetime | The nonce outlives the access token. | Lower store.nonceLifetimeSeconds to at most store.accessTokenLifetimeSeconds. |
evidence.oid4vci.secret-file-root | The file provider root is relative. | Write secretProviders.file.root as an absolute directory path. |
evidence.oid4vci.secret-provider-disabled | The key reference names a provider the file does not enable. | Declare that provider under secretProviders, or reference the key through an enabled one. |
evidence.oid4vci.audit-secret-provider-disabled | The audit hash key reference names a provider the file does not enable. | Declare that provider under secretProviders, or reference the audit key through an enabled one. |
evidence.oid4vci.invalid-audit | The audit destination settings do not describe a usable file or stdout destination. | Use an absolute file path with valid rotation and retention settings, or select stdout with no file-only settings. |
evidence.oid4vci.metrics-shared-binding | The metrics listener shares the delivery binding. | Bind the metrics listener to a port the delivery listener does not use. |
evidence.oid4vci.wildcard-spelled-item | An offers list item spells * or unrestricted, which matches only itself (a warning). | Write unrestricted in place of the list, or remove the item. |
invalid_proof (wallet response) | The proof failed a named check. | Read the check in error_description, then correct that header parameter or claim in the wallet’s proof. |
evidence-oid4vci runtime configurationSource preview, pre-1.0, 65 key paths
evidence-oid4vci runtime configuration
Source preview, pre-1.0
Generated from products/evidence/generated/oid4vci-runtime/oid4vci-runtime.schema.json. 65 key paths. Explained in docs/site/src/content/docs/configure/evidence-oid4vci.mdx.
| Key path | Type | Required | Accepted values | Constraints |
|---|---|---|---|---|
apiVersionThe format version of this file. | string | Yes |
| None specified |
auditThe required durable audit journal for offer attribution and delivery
outcomes. stdout is available for a collector-managed deployment;
only a file destination supplies durability itself. | object | Yes | Not specified | None specified |
audit.destinationThe configured audit destination kind, as written in product configuration. | string | No |
"file" | None specified |
audit.hashKeyRefSecret reference to the audit hash key. | string | Yes | Not specified |
|
audit.path | string | No | Not specified |
|
audit.retentionDays | integer | No | Not specified |
|
audit.rotateBytes | integer | No | Not specified |
|
credentialIssuerThe credential_issuer identifier this service publishes, and the
value a wallet proof must carry as its aud: a bare origin with no
path, query, or fragment. https in strict mode. A trailing slash is
removed, so what is published and what is compared are the same bytes. | string | Yes | Not specified |
|
draft13ProfileServes the OpenID4VCI Draft 13 compatibility profile beside the 1.0
Final profile, at its own issuer identifier: the credentialIssuer
followed by /protocol/oid4vci/draft-13. Written as an empty mapping,
draft13Profile: {}; absent means the profile is not served and none of
its routes exist. It shares the offer store and the offer authorization
with the 1.0 profile, and an offer made for one profile cannot be
redeemed through the other. | object | No | Not specified | None specified |
evidenceThe Evidence deployment this service requests credentials from. | object | Yes | Not specified | None specified |
evidence.baseUrlThe Evidence base URL, with no query or fragment. https in strict mode. | string | Yes | Not specified |
|
kindThe document kind. | string | Yes |
| None specified |
listenerThe listener a wallet and an adopter reach. | object | Yes | Not specified | None specified |
listener.bindSocket address the runtime listens on, written host:port with an IP address host ([addr]:port for IPv6). | string | Yes | Not specified |
|
listener.maximumRequestBytesThe largest request body accepted, in bytes. A limit no wallet request
can survive leaves the service reporting itself ready while every
request fails. | integer | No | Not specifiedSchema default, where declared: 16384 |
|
listener.requestTimeoutMillisecondsHow long one request may take, in milliseconds. | integer | No | Not specifiedSchema default, where declared: 5000 |
|
metricsListenerThe optional operator-only metrics listener: a loopback or private
address the delivery listener does not use. Absent means no metrics
endpoint exists. | object | No | Not specified | None specified |
metricsListener.bindSocket address the runtime listens on, written host:port with an IP address host ([addr]:port for IPv6). | string | Yes | Not specified |
|
offersThe authorization boundary of the adopter-facing offer endpoint. | object | Yes | Not specified | None specified |
offers.algorithmsThe one signature algorithm an offer token may be signed with. | array | No | Not specifiedSchema default, where declared: ["EdDSA"] |
|
offers.algorithms[]Array itemThe signature algorithms an offer token may be signed with.
The same closed vocabulary Evidence accepts on its own resource-server
profile. Mixing families is refused when the configuration loads rather than
when the first token arrives. | string | No |
| None specified |
offers.allowInsecureHttpPermit plain HTTP beyond loopback to the offer-token issuer and to
jwksUri. | boolean | No | Not specifiedSchema default, where declared: false | None specified |
offers.allowedClientsClient identifiers permitted to create offers, each at most 128 bytes,
or unrestricted for any client the issuer vouched for. | array | string | Yes |
| No bound or
|
offers.allowedClients[]Array itemAn identifier another system issues, written exactly as its issuer gives it. | string | Conditional | Not specified |
|
offers.audiencesThe audiences this service answers to, each at most 256 bytes. A token
issued for anything else is refused. | array | Yes | Not specified |
|
offers.audiences[]Array itemAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specified |
|
offers.claimsContextual claim names used by this offer-token issuer. Keep these
aligned with Evidence when both verify tokens from the same deployment. | object | No | Not specifiedSchema default, where declared: {"actorKind":"registry_actor_kind","approver":"registry_approver","grantBounds":"registry_grant_bounds","grantClient":"registry_grant_client","grantExp":"registry_grant_exp","grantId":"registry_grant_id","grantResource":"registry_grant_resource","grantSourceIssuer":"registry_grant_source_issuer","purpose":"registry_purpose"} | None specified |
offers.claims.actorKindAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_actor_kind" |
|
offers.claims.approverAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_approver" |
|
offers.claims.grantBoundsAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_grant_bounds" |
|
offers.claims.grantClientAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_grant_client" |
|
offers.claims.grantExpAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_grant_exp" |
|
offers.claims.grantIdAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_grant_id" |
|
offers.claims.grantResourceAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_grant_resource" |
|
offers.claims.grantSourceIssuerAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_grant_source_issuer" |
|
offers.claims.purposeAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specifiedSchema default, where declared: "registry_purpose" |
|
offers.issuerThe authorization server that issues offer tokens, compared exactly
against a token's iss. https in strict mode. | string | Yes | Not specified |
|
offers.jwksUriThe offer-token issuer's key set. https in strict mode. | string | Yes | Not specified |
|
offers.maximumTokenLifetimeSecondsThe longest lifetime an offer token may carry, in seconds. | integer | No | Not specifiedSchema default, where declared: 900 |
|
offers.requiredScopesScopes every offer token must carry, each an RFC 6749 scope token of at
most 256 bytes, or unrestricted for no scope gate. Generated
configurations state oid4vci:offer. | array | string | Yes |
| No bound or
|
offers.requiredScopes[]Array itemAn identifier another system issues, written exactly as its issuer gives it. | string | Conditional | Not specified |
|
rateLimitsRequest budgets. Absent means the defaults of each member. | object | No | Not specified | None specified |
rateLimits.offerBurstThe most offers one authorized principal may create in a burst. | integer | No | Not specifiedSchema default, where declared: 60 |
|
rateLimits.offersPerPrincipalPerMinuteOffers one authorized principal may create per minute. | integer | No | Not specifiedSchema default, where declared: 600 |
|
rateLimits.walletRequestBurstThe most requests each wallet endpoint admits in a burst. | integer | No | Not specifiedSchema default, where declared: 300 |
|
rateLimits.walletRequestsPerMinuteRequests per minute each of the token, nonce and credential endpoints
takes from all wallets together. | integer | No | Not specifiedSchema default, where declared: 3000 |
|
secretProvidersThe secret providers tokenClient.privateKeyRef may name. | object | Yes | Not specified | None specified |
secretProviders.environmentEnables secret:env/NAME references, read from the process
environment. Declared as an empty mapping: environment: {}. | object | Conditional | Not specified | None specified |
secretProviders.fileEnables secret:file/name references, read from files under root. | object | Conditional | Not specified | None specified |
secretProviders.file.rootAbsolute directory holding one file per secret. Each file must be a
regular file owned by the runtime user, mode 0400 or 0600, with exactly
one hard link. | string | Conditional | Not specified |
|
storeThe bounds of the in-memory store. | object | No | Not specifiedSchema default, where declared: {"accessTokenLifetimeSeconds":300,"maximumOffers":4096,"maximumTransactionCodeAttempts":3,"nonceLifetimeSeconds":120,"offerLifetimeSeconds":300} | None specified |
store.accessTokenLifetimeSecondsHow long an access token lives, in seconds. | integer | No | Not specifiedSchema default, where declared: 300 |
|
store.maximumOffersHow many offers the store holds. A store too small to hold a
deployment's live offers refuses new ones rather than forgetting old
ones. | integer | No | Not specifiedSchema default, where declared: 4096 |
|
store.maximumTransactionCodeAttemptsHow many wrong transaction codes an offer survives. | integer | No | Not specifiedSchema default, where declared: 3 |
|
store.nonceLifetimeSecondsHow long a nonce lives, in seconds; at most the access token lifetime. | integer | No | Not specifiedSchema default, where declared: 120 |
|
store.offerLifetimeSecondsHow long an offer lives, in seconds. Minutes, in both directions:
shorter than a person can scan a code and read a message, and the flow
cannot complete; longer, and a stolen offer stays useful. | integer | No | Not specifiedSchema default, where declared: 300 |
|
tokenClientThe identity this service authenticates to its token issuer with. | object | Yes | Not specified | None specified |
tokenClient.clientAssertionAudienceThe value the endpoint expects as the client assertion aud. Absent
means the token endpoint, which is the usual registration. | string | No | Not specified |
|
tokenClient.clientIdThe client identifier registered with the token issuer, at most 128
bytes. | string | Yes | Not specified |
|
tokenClient.privateKeyRefThe client's own private JWK, as secret:file/<name> under
secretProviders.file.root or secret:env/<NAME>. Resolved when the
outbound client is built, never logged and never rendered. | string | Yes | Not specified |
|
tokenClient.resourceThe fixed RFC 8707 resource identifier of the Evidence API: an
absolute URI with no fragment or user information. | string | No | Not specified |
|
tokenClient.scopesThe exact scopes requested from the issuer for Evidence access: 1 to
32 distinct RFC 6749 scope tokens. Absent requests no scope. | array | No | Not specified |
|
tokenClient.scopes[]Array itemAn identifier another system issues, written exactly as its issuer gives it. | string | No | Not specified |
|
tokenClient.tokenEndpointThe token endpoint, with no query or fragment. https in strict mode. | string | Yes | Not specified |
|
validationModeThe transport validation boundary. Absent means strict. | string | No |
"strict" | None specified |
Cookies on Registry Stack
We use optional cookies to understand how you use the main Registry Stack website and this documentation. If you contact us, we may link this activity to your enquiry. You can change your choice at any time. Read the privacy policy.
Your browser asks sites not to track you, so optional tracking stays off.
We could not use or save that choice. Optional tracking remains off.