Cached Trust Proofs
Store a short-lived, encrypted proof of a completed verification and check it again later, including from a terminal that cannot reach the identity provider.
Overview
A cached trust proof is a record that a subject was verified, stored encrypted with an expiry, and checked again later by its id. The payload is whatever the issuing system wants to carry forward, for example a credential type and issuer. The platform encrypts it, stores a content digest for integrity, and answers validity questions about it until it expires or is revoked.
The name describes the intent, keeping a verification usable where connectivity is poor. Validation itself is an online call to shield-cached-proof: the terminal presenting a proof must reach PasskeyBridge, and nothing in the platform verifies a proof offline.
Who can call it. A signed-in user's JWT, sent as Authorization: Bearer [session JWT]. There is no API key lane. The tenant is resolved from the caller's oldest membership rather than from the request body, so a user in several tenants always acts in the same one here. The tenant's plan must be enterprise, checked directly against the tenant row.
Where it lives. https://api.passkeybridge.io/v1/shield-cached-proof. The function is edge locked, so a call to the Supabase functions host answers 403 Direct access denied.
Identifiers. user_identifier is sent raw and hashed server side with keyed HMAC-SHA-256 under the server-held pepper before anything is stored. The stored user_hash is never reversible, and a missing pepper fails the write closed. Rows written before keyed hashing carry hash version 1 and are still matched on read.
Proof lifecycle
Store. A proof is created after your own verification succeeds. The payload is serialised, digested with SHA-256 into proof_hash for the integrity check, encrypted with AES-256-GCM, and inserted with an expiry.
Validate. A caller presents the proof id. The platform checks that the row exists in this tenant, that is_valid is true and revoked_at is unset, and that expires_at is in the future, and marks the row invalid if it has passed. When the caller also sends proof_payload, the platform re-digests it and compares it with the stored proof_hash. Without a payload only existence and status are confirmed, and the response says so in reason.
Revalidate. An explicit call that stamps revalidated_at, sets network_available to true and writes a cached_proof_revalidated event. It is an action you invoke when a terminal comes back online. Nothing queues it, and no background job runs it.
Revoke. An admin-only call that sets is_valid false and stamps revoked_at.
Proofs are also invalidated without anyone calling this function:
cleanup_expired_records()flipsis_validto false for every proof whoseexpires_athas passed.- The
freeze_credentialsplaybook action invalidates every valid proof in the tenant and stampsrevoked_at. - The
quarantineplaybook action does the same alongside cross-references and shadow identities. Both are tenant-wide rather than per subject; see playbooks. - An Art. 17 erasure overwrites
proof_payload_encryptedwithSHREDDED, clears the signature and setsis_validfalse for the subject's proofs.
Trust levels are standard, high and critical, with standard the default. They are labels you choose and store; the platform does not derive them from how a subject was verified and does not treat one differently from another at validation time.
network_available records whether the proof was stored while the issuer had connectivity. It defaults to true and is set false only when you send network_available: false.
Hybrid signatures on proofs
A proof is signed only when the tenant has an active credential provider row with pqc_enabled set. Otherwise pqc_signature and pqc_algorithm are stored as null and the proof is protected by encryption and the content digest alone.
When signing is enabled, the platform signs the proof's content digest twice and stores both parts in one JSON value:
- A classical HMAC-SHA-256 signature under a key derived from the platform key and the tenant domain. This layer is symmetric and server held.
- An ML-DSA signature, FIPS 204. The parameter set follows the tenant's configured level: ML-DSA-65 by default, ML-DSA-87 when the tenant is set to level 5.
pqc_algorithm records which parameter set was used, so a later verifier selects the right key. The stored value also carries the quantum nonce mixed into the signed payload and the key fingerprint.
Two limits matter:
- Validation does not check the signature. The
validateaction checks expiry, revocation and, when you supply the payload, the SHA-256 content digest. The stored hybrid signature is not verified on that path today, by this function or by the dashboard. - Signing failures are not fatal. If signing raises, the error is logged and the proof is stored unsigned. A null
pqc_algorithmin the store response is how you detect that.
Entropy provenance for the signature nonce is written to the entropy pool before the proof row that names it, so a signed proof can be traced to its seed. See PQC migration.
Actions, requests and responses
Five actions on POST https://api.passkeybridge.io/v1/shield-cached-proof, all with an Authorization bearer header carrying the session JWT.
| Action | Who | Purpose |
|---|---|---|
store | Any tenant member | Create a proof |
validate | Any tenant member | Check a proof by id |
revalidate | Any tenant member | Stamp a proof as re-checked and online |
revoke | Tenant admin | Invalidate a proof |
list | Any tenant member, admin when filtering by identifier | List up to 100 proofs |
Store request.
{
"action": "store",
"user_identifier": "+15551234567",
"proof_type": "vc_cache",
"proof_payload": { "credential_type": "government_id", "issuer": "did:web:issuer.example.com" },
"trust_level": "high",
"expires_in_hours": 24,
"network_available": true,
"metadata": { "terminal": "lobby-1" }
}| Field | Type | Required | Default and rules |
|---|---|---|---|
user_identifier | string | yes | Non-empty. Hashed server side, never stored raw |
proof_payload | object or string | yes | Serialised before encryption |
proof_type | string | no | trust_token, vc_cache or passkey_attestation. Any other value silently becomes trust_token |
trust_level | string | no | standard, high or critical. Any other value silently becomes standard |
expires_in_hours | number | no | 1 to 720. Default 72. Outside the range the request is rejected |
network_available | boolean | no | Defaults to true; only an explicit false changes it |
metadata | object | no | Stored as given, defaults to an empty object |
There is no tenant_id field and no ttl_hours field. The unrecognised-value coercions mean a typo in proof_type or trust_level returns 200 with a different value than you sent, so read the response back.
Store response.
{
"proof": {
"id": "6f0a8e1c-5f3a-4a2e-9f12-2a7d4c9b8e30",
"proof_type": "vc_cache",
"proof_hash": "<sha256 hex of the payload>",
"trust_level": "high",
"issued_at": "2026-09-16T14:30:00.000Z",
"expires_at": "2026-09-17T14:30:00.000Z",
"network_available": true,
"pqc_algorithm": "ML-DSA-65"
}
}Validate request and response. proof_id is a UUID; proof_payload is optional and turns on the integrity check.
{ "action": "validate", "proof_id": "6f0a8e1c-5f3a-4a2e-9f12-2a7d4c9b8e30", "proof_payload": { "credential_type": "government_id", "issuer": "did:web:issuer.example.com" } }{
"valid": true,
"integrity_checked": true,
"reason": "Proof is valid, integrity verified",
"proof_type": "vc_cache",
"trust_level": "high",
"issued_at": "2026-09-16T14:30:00.000Z",
"expires_at": "2026-09-17T14:30:00.000Z",
"network_available": true,
"revalidated_at": null
}A revoked or expired proof answers 200 with valid: false and reason set to Proof has been revoked or Proof has expired. A payload mismatch answers 200 with valid: false and Integrity check failed, payload hash mismatch.
Revalidate takes proof_id and returns { "revalidated": true, "proof_type": ..., "trust_level": ..., "revalidated_at": ... }. An expired proof answers 200 with revalidated: false and is marked invalid.
Revoke takes proof_id and returns { "revoked": true }.
List takes no required fields and returns { "proofs": [...] }, the 100 most recent rows for the tenant with their type, hash, trust level, user hash, hash version, timestamps, validity and network flag. Adding user_identifier narrows the list to one subject, matched across the current pepper, the previous pepper during a rotation and the legacy unkeyed digest. That filter is admin only, because submitting a raw identifier and reading back whether a row exists would otherwise be a way to confirm membership.
Errors
| Status | Body | Cause |
|---|---|---|
| 401 | Missing or malformed authorization header | No bearer token |
| 401 | Invalid auth token | Token did not resolve to a user |
| 404 | No tenant found | The user has no tenant membership |
| 403 | Cached Proof System requires an Enterprise subscription | Tenant plan is below Enterprise; the body also carries upgrade_hint |
| 403 | Direct access denied. Use https://api.passkeybridge.io/v1/{function}. | Request bypassed the public host |
| 400 | Unknown action. Valid: store, validate, revalidate, revoke, list | Action missing or unrecognised |
| 400 | Missing or invalid user_identifier (must be a non-empty string) | Store without an identifier |
| 400 | Missing proof_payload | Store without a payload |
| 400 | expires_in_hours must be between 1 and 720 | TTL out of range or not a number |
| 400 | Missing or invalid proof_id (must be UUID) | Validate, revalidate or revoke without a UUID |
| 400 | Invalid JSON body | Body did not parse |
| 413 | Payload too large | Request body above 64 KB |
| 403 | Only admins can revoke cached proofs | Revoke by a non-admin member |
| 403 | Only admins can list cached proofs by user_identifier | Filtered list by a non-admin member |
| 404 | { "valid": false, "reason": "Proof not found" } | Unknown id, or a proof in another tenant |
| 404 | { "revoked": false, "reason": "Proof not found, already revoked, or belongs to another tenant" } | Revoke that matched nothing |
| 502 | Failed to store cached proof and similar | The database call failed; the specific message is in the function log |
| 500 | Internal server error | Anything unhandled, including a missing hash pepper |
Note the split: a lookup that finds nothing answers 404, while a proof that is found and unusable answers 200 with valid: false. Treat only valid: true as a pass.
Verify it worked
- Read the
storeresponse.idis what you present later, andpqc_algorithmtells you whether the proof was signed. - Call
validatewith that id and the same payload.validistrueandintegrity_checkedistrue. - Open Dashboard, Offline trust tokens. The tab is Enterprise only and lists the 100 most recent proofs with tiles for total, valid, revalidated and revoked. Find your proof by the first 16 characters of its proof hash and check the Status column reads Valid and Network reads Online.
- After a
revalidate, the Revalidated tile increases and an event of typecached_proof_revalidatedis written for the tenant.
One display quirk to expect: the tab maps the trust levels High, Standard and Low. A proof stored as critical is a valid value that the table renders as Unknown in the Trust column. The stored value is unchanged, and list and validate both return critical correctly.