Skip to main content
Version: Latest (v0.0.1)

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 fetches its JWKS from {issuer}/oauth2/jwks (the same path its own /.well-known/openid-configuration publishes) and verifies the credential's signature against it.

Known limitations

  • SD-JWT VC only. JWT-VC and mdoc presentation verification aren't implemented yet.
  • O2ID-shaped issuers only. The JWKS is fetched from a fixed {issuer}/oauth2/jwks path rather than via full OIDC discovery — an external issuer with a different jwks_uri isn't supported yet.
  • Unsigned authorization requests. The openid4vp:// request is sent by value, not as a signed request object.
  • No holder-binding replay protection yet. A presented credential's key-binding proof isn't checked against the request — a captured presentation could in principle be replayed within the challenge's 10-minute lifetime.

API Reference

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