# Verifying credential presentations

This guide covers the **verifier** side of [verifiable credentials](/truora-pass/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

- Your application must be registered with Truora Pass — you authenticate these endpoints with your `client_id` and `client_secret`. See [Registering your application](/truora-pass/registering_your_application/).
- The user must hold an issued identity credential in their Truora Pass wallet. See [Issuing and sharing credentials](/truora-pass/sharing_credentials/) for that flow.

## 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 |

```bash
curl https://api.pass.truora.com/.well-known/openid-credential-issuer
```

```json
{
  "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.

```bash
curl https://api.pass.truora.com/.well-known/jwt-vc-issuer
```

```json
{
  "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`:

```json
{
  "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 authentication** — `client_id` as the username, `client_secret` as the password. Send a JSON request with the claims you want the user to disclose:

```bash
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](/truora-pass/verifiable_credentials/#the-identity-credential). |
| `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:

```json
{
  "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:

```text
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:

```bash
curl https://api.pass.truora.com/v1/vp/requests/<request_id>/result \
  --user "WLT_APP_your_client_id:your_client_secret"
```

```json
{
  "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](/truora-pass/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 codes** — `credential_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](/truora-pass/sharing_credentials/#sharing-via-link-or-qr)** (`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](#verifying-a-shared-credential) — you can verify the `vp_token` offline against the JWKS. A presented `vp_token` is:

```text
<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](/truora-pass/sharing_credentials/)). The share is public — no authentication — and returns the verified claims together with the raw presentation:

```bash
curl https://api.pass.truora.com/v1/vp/shares/<share_id>
```

```json
{
  "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](#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](#verify-the-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.
