Verifying credential presentations

This guide covers the verifier side of verifiable credentials: your application asks the user to present claims from their Truora Pass identity credential, the user approves the disclosure in Truora Pass, and your application receives a cryptographically verified result — with the option to re-verify the raw presentation yourself, offline, against the published Truora Pass keys.

Hosts

Truora Pass verifiable credentials span two hosts, and it matters which one you call:

Host Role What lives here
pass.truora.com Issuer identity + wallet The iss/credential_issuer identifier, the discovery documents (/.well-known/openid-credential-issuer, /.well-known/jwt-vc-issuer), and the wallet UI the user interacts with (the /present/<request_id> consent screen, public share pages)
api.pass.truora.com API gateway The REST endpoints you call — /v1/vp/*, /v1/credentials/*, /v1/oauth2/jwks — and every fetchable URL embedded in a credential (jwks_uri, credential_endpoint, the status-list uri, request_uri)

The split is intentional: pass.truora.com is a stable identifier (the value of the iss claim) and the human-facing wallet, while api.pass.truora.com serves machine endpoints. Always resolve endpoint URLs from the discovery document (jwks_uri, credential_endpoint, a credential’s status-list uri) rather than hardcoding them — the examples below show the current production hosts, but the discovery document is authoritative.

Prerequisites

Flow at a glance

  1. Your backend creates a presentation request at POST /v1/vp/requests, listing the claims you need, and receives a request_id and a request_uri.
  2. The user reviews the request in Truora Pass — your application’s name and the requested claims — and approves the disclosure (possibly a subset).
  3. The wallet builds the presentation (vp_token) and delivers it to Truora Pass, which verifies it.
  4. Your backend polls the request until the result is verified (or rejected) and reads the disclosed claims.

Discovery endpoints

Truora Pass publishes its issuer metadata and public keys at standard, unauthenticated locations:

Endpoint What it serves
GET /.well-known/openid-credential-issuer OpenID4VCI issuer metadata: the issuer identifier, the supported credential configurations, and the issuance endpoints
GET /.well-known/jwt-vc-issuer SD-JWT VC issuer metadata: the issuer identifier and the jwks_uri
GET /v1/oauth2/jwks The JWKS — the public keys credentials are verified against
curl https://api.pass.truora.com/.well-known/openid-credential-issuer
{
  "credential_issuer": "https://pass.truora.com",
  "credential_endpoint": "https://api.pass.truora.com/v1/oauth2/credentials",
  "nonce_endpoint": "https://api.pass.truora.com/v1/oauth2/credentials/nonce",
  "credential_configurations_supported": {
    "urn:truora:pass:identity:1": {
      "format": "dc+sd-jwt",
      "vct": "urn:truora:pass:identity:1",
      "credential_signing_alg_values_supported": ["ES256"]
    }
  }
}

Note: the credential_endpoint and nonce_endpoint are used by the Truora Pass wallet itself during issuance — your application never calls them.

curl https://api.pass.truora.com/.well-known/jwt-vc-issuer
{
  "issuer": "https://pass.truora.com",
  "jwks_uri": "https://api.pass.truora.com/v1/oauth2/jwks"
}

The JWKS contains one or more EC P-256 signing keys, each identified by kid:

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "kid": "<key identifier>",
      "use": "sig",
      "alg": "ES256",
      "x": "<base64url x coordinate>",
      "y": "<base64url y coordinate>"
    }
  ]
}

Note: always resolve the key set through the jwks_uri from the discovery document instead of hardcoding a URL, and select the key by the credential’s kid header. Retired keys stay published so that credentials signed with them keep verifying — but new credentials are only ever signed with the active key.

1. Create a presentation request

The verifier endpoints authenticate with your client credentials over HTTP Basic authenticationclient_id as the username, client_secret as the password. Send a JSON request with the claims you want the user to disclose:

curl -X POST https://api.pass.truora.com/v1/vp/requests \
  --user "WLT_APP_your_client_id:your_client_secret" \
  --header 'Content-Type: application/json' \
  --data '{
    "requested_claims": ["given_name", "family_name", "birthdate", "document_number"],
    "purpose": "Account opening at CapiBank"
  }'
Parameter Required Description
requested_claims Yes The claims you are asking the user to disclose. Each must be one of the identity credential’s disclosable claims — given_name, family_name, birthdate, document_number, document_type, country, nationality; see the identity credential claims.
purpose No A short human-readable explanation of why you are asking, shown to the user on the consent screen.

On success the endpoint returns:

{
  "request_id": "<opaque request identifier>",
  "request_uri": "<URL of the signed request object>",
  "expires_at": "2026-07-16T14:13:00Z"
}
  • request_id identifies this presentation request — you use it to send the user to the consent screen and to poll for the result.
  • request_uri resolves to the request object — a JWT signed by Truora Pass that pins your application’s identity, the requested claims, the purpose, and the single-use nonce the wallet must bind its presentation to. The Truora Pass wallet fetches it with the user’s session; your application does not call it.
  • expires_at is when the request stops being presentable.

2. The user approves in Truora Pass

Send the user to the Truora Pass presentation consent screen:

https://pass.truora.com/present/<request_id>

There the user sees your application’s name, the requested claims, and your purpose, and approves the disclosure — the user may approve a subset of what you asked for. Design your flow so that claims you asked for but did not receive are treated as declined, not as an error.

3. The wallet delivers the presentation

This step happens between the Truora Pass wallet and Truora Pass — your application is not involved. The wallet builds the vp_token (the SD-JWT, the disclosures the user approved, and a KB-JWT signed by the credential’s holder key over the request’s nonce, audience, and presentation hash) and submits it to POST /v1/vp/responses. Truora Pass verifies the full chain — issuer signature against the JWKS, disclosure digests, KB-JWT signature against the credential’s cnf.jwk, nonce (single use), audience, sd_hash, and the revocation status list — and stores the verified result under your request_id.

4. Poll for the result

Fetch the request’s result with your client credentials (HTTP Basic, as above) until it leaves the pending state:

curl https://api.pass.truora.com/v1/vp/requests/<request_id>/result \
  --user "WLT_APP_your_client_id:your_client_secret"
{
  "status": "verified",
  "claims": {
    "given_name": "Ana",
    "family_name": "García",
    "birthdate": "1992-04-15",
    "document_number": "1032456789"
  },
  "verified_at": "2026-07-16T14:07:12Z"
}

Note: only the application that created the request can read its result — any other credentials get a 404.

status Meaning
pending The user has not completed the presentation yet. Keep polling.
verified The presentation verified. claims contains exactly the claims the user disclosed — no more, no less. Claims the user declined are absent.
rejected The presentation failed verification. The result carries an error code — see below.

Error codes

All verifiable-credential endpoints use the standard Truora Pass error envelope (code, status, message, error_code) described in Errors and troubleshooting.

Request and authentication errors — returned by the verifier API calls (POST /v1/vp/requests, the result poll) when the call itself is malformed or unauthenticated:

HTTP status error_code When
401 unauthorized The Authorization header is missing or is not valid HTTP Basic
401 bad_credentials Wrong client_id / client_secret
400 validation_error Malformed request body, an empty requested_claims, or a requested claim that is not one of the identity credential’s disclosable claims

Presentation-failure codescredential_revoked and invalid_presentation describe why a presentation was not accepted, and they surface in two different places depending on how the presentation was submitted:

error_code When
credential_revoked The presented credential has been revoked — its status-list bit is set
invalid_presentation The presentation failed cryptographic verification: bad issuer signature, tampered disclosures, a failed KB-JWT check (signature, audience, or sd_hash), or a missing, expired, or already-used nonce (nonces are single-use — a replayed presentation fails here)
  • When you poll a presentation request (GET /v1/vp/requests/{request_id}/result), the HTTP call succeeds with 200 and the failure is reported in the body as "status": "rejected" with the matching error_code. The presentation is delivered by the wallet, so it is not a 4xx on your call — it is a completed request whose outcome is a rejection.
  • When a user creates a share (POST /v1/vp/shares, submitted by the wallet), the same two codes come back as an HTTP 400 on that request — a share is only ever created over a presentation that verified, so a failing presentation is rejected synchronously.

Verify the chain yourself

The verified result above is enough for most integrations. If your compliance requirements demand independent verification — or you received a raw presentation through a share link — you can verify the vp_token offline against the JWKS. A presented vp_token is:

<issuer-signed JWT>~<disclosure 1>~…~<disclosure N>~<KB-JWT>
  1. Split the token on ~. The first element is the issuer-signed SD-JWT, the last is the KB-JWT, everything in between is a disclosure.
  2. Verify the issuer JWT: check the header (alg: ES256, typ: dc+sd-jwt), look up the header’s kid in the JWKS from jwks_uri, and verify the signature. Then check the payload: iss is https://pass.truora.com, vct is urn:truora:pass:identity:1, and exp is in the future.
  3. Verify each disclosure: base64url-decode it to a JSON array [<salt>, <claim name>, <claim value>], compute base64url(SHA-256(<disclosure string>)), and require the digest to be present in the payload’s _sd array (_sd_alg is sha-256). Reject the presentation if any disclosure does not match a digest.
  4. Verify the KB-JWT: check its header (typ: kb+jwt) and verify its signature against the public key in the issuer JWT’s cnf.jwk claim — this proves possession of the holder key. Then check its payload: nonce and aud must match the values pinned in the signed request object, and sd_hash must equal base64url(SHA-256(...)) of the exact presentation bytes up to and including the ~ that precedes the KB-JWT.
  5. Check revocation: fetch the URL in the payload’s status.status_list.uri. It returns a compact JWT (application/statuslist+jwt) — verify its signature against the same JWKS, then base64url-decode and zlib-inflate its status_list.lst value into a bitstring and read the bit at status.status_list.idx (one bit per credential). A set bit means the credential is revoked.

Verifying a shared credential

Users can also share their credential proactively through a link or QR code (see Issuing and sharing credentials). The share is public — no authentication — and returns the verified claims together with the raw presentation:

curl https://api.pass.truora.com/v1/vp/shares/<share_id>
{
  "claims": {
    "given_name": "Ana",
    "family_name": "García"
  },
  "issuer": "https://pass.truora.com",
  "verified_at": "2026-07-16T14:03:00Z",
  "expires_at": "2026-07-23T14:03:00Z",
  "vp_token": "<issuer-signed JWT>~<disclosure 1>~…~<KB-JWT>"
}

Truora Pass verified the presentation when the share was created — the response carries the claims exactly as disclosed, when they were verified, and the raw vp_token so you can re-verify the chain yourself. Shares expire 7 days after creation and can be revoked earlier by the user; an expired or revoked share returns 404.

Security note: if your application will act on a shared credential — not just display it — treat the claims field of the API response, and anything rendered on the pass.truora.com share page, as untrusted input. Fetch the vp_token and verify its signature chain yourself against the Truora Pass JWKS before relying on the claims. The rendered share page is a human-facing convenience, not an authenticated attestation to your backend; only your own verification of the vp_token against the published keys is authoritative.