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
- Your application must be registered with Truora Pass — you authenticate these endpoints with your
client_idandclient_secret. See Registering your application. - The user must hold an issued identity credential in their Truora Pass wallet. See Issuing and sharing credentials for that flow.
Flow at a glance
- Your backend creates a presentation request at
POST /v1/vp/requests, listing the claims you need, and receives arequest_idand arequest_uri. - The user reviews the request in Truora Pass — your application’s name and the requested claims — and approves the disclosure (possibly a subset).
- The wallet builds the presentation (
vp_token) and delivers it to Truora Pass, which verifies it. - Your backend polls the request until the result is
verified(orrejected) 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 authentication — client_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_ididentifies this presentation request — you use it to send the user to the consent screen and to poll for the result.request_uriresolves 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_atis 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 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 with200and the failure is reported in the body as"status": "rejected"with the matchingerror_code. The presentation is delivered by the wallet, so it is not a4xxon 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 HTTP400on 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>
- 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. - Verify the issuer JWT: check the header (
alg: ES256,typ: dc+sd-jwt), look up the header’skidin the JWKS fromjwks_uri, and verify the signature. Then check the payload:issishttps://pass.truora.com,vctisurn:truora:pass:identity:1, andexpis in the future. - Verify each disclosure: base64url-decode it to a JSON array
[<salt>, <claim name>, <claim value>], computebase64url(SHA-256(<disclosure string>)), and require the digest to be present in the payload’s_sdarray (_sd_algissha-256). Reject the presentation if any disclosure does not match a digest. - Verify the KB-JWT: check its header (
typ: kb+jwt) and verify its signature against the public key in the issuer JWT’scnf.jwkclaim — this proves possession of the holder key. Then check its payload:nonceandaudmust match the values pinned in the signed request object, andsd_hashmust equalbase64url(SHA-256(...))of the exact presentation bytes up to and including the~that precedes the KB-JWT. - 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 itsstatus_list.lstvalue into a bitstring and read the bit atstatus.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.