Skip to main content
Version: Next

Verifying Credentials

O2ID can act as an OID4VP verifier: a verifiable_credential_presentation authenticator asks the subject to present a credential from their wallet, checks its signature and issuer against an explicit trust list, and feeds the disclosed claims into the rest of the flow — "log in by presenting your employer's badge," or a step-up check partway through an already-authenticated flow. It's configured and referenced exactly like TOTP: create an authenticator instance, then point an authenticator flow node at its ID. There is no separate node type for this.

This is the mirror image of issuing credentials: O2ID is the relying party here, not the issuer. The credential being presented was typically issued by some other system (possibly another O2ID tenant, possibly an entirely external issuer) — see Trusting an issuer below for what "external" requires.

Configuring the authenticator

o2idctl authenticators create \
--type verifiable_credential_presentation \
--display-name "Employee Badge Presentation" \
--field vct=EmployeeBadge \
--field trustedIssuers=https://partner.example.com/t/system \
--field requiredClaims=department
FieldRequiredDescription
vctYesThe credential type the wallet must present — checked against the presented credential's own vct claim
trustedIssuersYesComma-separated allow-list of issuer URLs. There is no "accept any issuer whose JWKS resolves" mode — an issuer not on this list is rejected before any network request is made to it
requiredClaimsNoComma-separated claim names that must be present (after selective disclosure) for the presentation to succeed

Then reference it from a flow the same way as any other authenticator:

{
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "deny",
"properties": {
"authenticatorId": "<verifiable_credential_presentation-authenticator-id>",
"maxAttempts": 3,
"onLockout": "deny"
}
}

maxAttempts/onLockout work exactly like they do for TOTP: each failed presentation counts as an attempt, and hitting the limit routes to onLockout instead of re-issuing another challenge.

What happens at runtime

  1. The node pauses immediately (no fields to submit) — the client receives a nodeView of type authenticator whose data.requestUri is an openid4vp:// deep link/QR code.
  2. The wallet (often a different device than the one that scanned the code) presents the credential by POSTing directly to POST /oid4vp/response — unauthenticated, gated by the single-use challenge ID embedded in the request as state.
  3. O2ID verifies the credential (see below) and resumes the flow past the authenticator node, routing to onSuccess or onFailure.
  4. Because step 2 may happen on a different device, the portal polls GET /flow-runs/{id} until the run advances, rather than waiting for a redirect back to the same browser tab (contrast with a connection node's federation redirect).

Trusting an issuer

There is no "accept any issuer whose JWKS resolves" mode. A presented credential's own iss claim is checked against trustedIssuers before any network request is made to it — an issuer not on the list is rejected outright. For a trusted issuer, O2ID discovers its jwks_uri via {issuer}/.well-known/openid-configuration and verifies the credential's signature against it — any issuer that publishes standard OIDC discovery works, not only ones that happen to share O2ID's own JWKS path convention.

Holder-binding replay protection

Each presentation request carries a freshly minted nonce (distinct from the state used to correlate the response with the pending flow run). When the presented credential was issued with a cnf key (see Issuing Verifiable Credentials), O2ID requires and verifies a Key-Binding JWT (KB-JWT) at the end of the presentation: it must be signed by that cnf key and its own nonce claim must match the nonce O2ID generated for this exact request. A captured presentation replayed against a different request — even one for the same flow, moments later — fails, because it carries the wrong nonce. A credential issued without a cnf key (bearer-only) has no key to bind to, so no KB-JWT is required or checked for it.

Known limitations

  • SD-JWT VC only. JWT-VC and mdoc presentation verification aren't implemented yet (mdoc issuance is supported — see Managing Verifiable Credentials — but presenting one back for verification is not).
  • Unsigned authorization requests. The openid4vp:// request is sent by value, not as a signed request object.
  • No aud binding check on the KB-JWT. Only the nonce is verified; the KB-JWT's aud claim (if the wallet sets one) isn't checked against a specific verifier identifier.

API Reference

See the API Reference for POST /oid4vp/response's request/response shape.