Verifying tokens in your API
For backend and API developers. At the end of this page, your API verifies the attestation header and decides what a request without a valid token may do.
Requests from attested apps carry one header:
TrustPin-Attestation: <token>The token is a JWT signed by your verifier with ES256. Your API, or your gateway, verifies it like any other JWT issued by a trusted issuer, then applies the attestation claims.
Token format
Header: alg: ES256, typ: trustpin-attest+jwt, kid naming one of the verifier’s signing keys.
{
"iss": "<TRUSTPIN_TOKEN_ISSUER>",
"aud": "<TRUSTPIN_TOKEN_AUDIENCE>",
"sub": "<device key id>",
"iat": 1792400000,
"exp": 1792403600,
"jti": "<random>",
"bind": "<base64url(SHA-256(bound value))>",
"tp": {
"prj": "tprn::project::<organization id>::<project id>",
"plat": "apple",
"app": "ABCDE12345.com.example.app",
"ev": ["app_attest"],
"env": "production",
"pv": 7,
"mode": "enforce",
"result": "pass",
"reasons": []
}
}| Claim | Meaning |
|---|---|
iss | The verifier’s issuer: by default tprn::attest::<organization id>::<project id>, or its public URL when discovery is on |
aud | The project audience: by default tprn::project::<organization id>::<project id> |
sub | The device key. It changes whenever the device attests a new key (reinstall, key-certificate expiry, rejected key, reset), so it is not a stable device or installation identifier |
iat, exp, jti | Issue and expiry times, and a random ID |
bind | Present only when the app bound the token to a session value; the digest of that value |
tp.prj | The project, tprn::project::<organization id>::<project id> |
tp.plat | apple, android or debug |
tp.app | <teamId>.<bundleId> on Apple, the package name on Android |
tp.ev | An array of the evidence that was verified: app_attest, key_attestation, play_integrity, debug_token |
tp.env | production, development (Xcode-run builds, when the policy allows them) or debug (debug tokens) |
tp.pv | Policy version the decision was made under |
tp.mode, tp.result, tp.reasons | The policy outcome; see Monitor and enforce |
Verification steps
- Fetch and cache
GET <verifier>/.well-known/jwks.json. Refetch when a token carries an unknownkid, at most once every 30 s, with a 5 s fetch timeout. - Reject any
algother thanES256and anytypother thantrustpin-attest+jwt. The type check also rejects key certificates. - Check the signature against the JWKS key named by
kid. - Check
iss,audandexp, allowing at most 60 seconds of clock skew, and thattp.prjequalstprn::project::<organization id>::<project id>. Refuse a token without it. - If you use binding: require
bindand compare it withbase64url(SHA-256(<the agreed value>)), unpadded, over the header value exactly as received. With the SDKs’bindToHeader("Authorization"), the agreed value is the request’sAuthorizationheader. With Require token binding on, turn on the require-binding option so a token withoutbindis refused. - Reject
tp.env == "debug"outside development environments. - Apply the mode rules below.
Gateways that locate keys through OIDC discovery can use <verifier>/.well-known/openid-configuration when the verifier’s TRUSTPIN_TOKEN_ISSUER is its public https:// URL. Built-in JWT authorizers can’t check typ, tp.prj, debug tokens or binding; see Checking tokens at the gateway.
Monitor and enforce
| Policy mode | What the token says | What your API should do |
|---|---|---|
| Enforce | New tokens always carry tp.result pass; a failing device gets no token. Tokens issued before a switch to enforce keep fail until they expire, up to one Access token lifetime, and the verifier can’t recall them | Reject requests without a valid token on the endpoints you protect. If you need an immediate cut-off, reject tp.result == "fail" yourself |
| Monitor | tp.result may be fail, with tp.reasons listing the policy checks that failed | Log the result and reasons, and allow the request. Use this phase to size the impact before switching to enforce |
Forged evidence never yields a token, and an app the policy doesn’t list gets 403 app_not_allowed in both modes.
Requests without the header
The SDKs never block the app’s own requests, so a request without the header is your decision, per endpoint. These requests come from old app versions, unsupported devices, attestation failures, and requests still waiting on a token. Common choices are to refuse sensitive operations, rate-limit harder, or flag the session.
Assertions for sensitive operations
From SDK 7.1.0 and verifier contract version 2, an app can send, next to the token, an assertion bound to one operation:
TrustPin-Attestation: <token>
TrustPin-Assertion: <base64url(clientData)>.<base64url(signature)>The client data names a single-use nonce the SDK fetched from your verifier, the app’s SHA-256 digest of the operation (payload), and the token’s binding. Your API decides which endpoints require it. To verify one, compute your own digest of the same bytes the app digested (for example the request body) and call the verifier:
POST <verifier>/v1/assertion
Content-Type: application/json
{ "token": "<TrustPin-Attestation value>", "assertion": "<TrustPin-Assertion value>", "payload": "<base64url(SHA-256 digest)>" }A 200 returns the token’s claims with the verified assertion; anything else (403 attestation_failed with assertion_invalid, payload_mismatch, key_mismatch or counter_invalid; 401 invalid_challenge for an expired or reused nonce) means the operation must be refused. The verifier checks the token itself, so the API need not verify it separately for that request. Access tokens carry the device public key (cnf) from contract version 2 for this purpose; APIs that do not verify assertions ignore it.
Example (Node.js, jose)
This is the verifier’s tested example, on jose 6.x:
import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
// project: the expected tp.prj (tprn::project::<organizationId>::<projectId>);
// omitted, it is the audience when the audience has that form (the default),
// otherwise there is no project check.
// requireBind: refuse tokens without `bind` (policy tokens.binding: required).
export function makeVerifier({ jwksUrl, issuer, audience, project, requireBind = false }) {
const expectedProject = project || (audience.startsWith("tprn::project::") ? audience : "");
// Caches the keys for 5 minutes and refetches on an unknown kid at most
// once per 30 s (an unknown kid inside the cooldown is refused without a
// fetch); concurrent misses share one fetch, which gives up after 5 s.
const jwks = createRemoteJWKSet(new URL(jwksUrl), {
cacheMaxAge: 300_000,
cooldownDuration: 30_000,
timeoutDuration: 5_000,
});
return async function verify(token, authorization) {
const { payload } = await jwtVerify(token, jwks, {
algorithms: ["ES256"],
typ: "trustpin-attest+jwt",
issuer,
audience,
clockTolerance: 60,
requiredClaims: ["exp"],
});
if (expectedProject && payload.tp?.prj !== expectedProject) throw new Error("wrong project");
if (payload.tp?.env === "debug") throw new Error("debug token");
if (payload.bind === undefined && requireBind) throw new Error("unbound token");
if (payload.bind !== undefined) {
const expected = createHash("sha256").update(authorization).digest("base64url");
if (payload.bind !== expected) throw new Error("bound to another session");
}
return payload;
};
}The same rules are available as downloads for Go, Node.js, Python and Java.
These samples may be copied and modified freely, under the verifier licence’s Code Samples clause.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Every token is refused on iss, aud or tp.prj | The API expects a different issuer, audience or project from the verifier’s | Compare them with TRUSTPIN_TOKEN_ISSUER and TRUSTPIN_TOKEN_AUDIENCE |
| Tokens are refused as expired or not yet valid | Clock skew | Synchronise the API’s and the verifier’s clocks with NTP |
| Binding mismatch | The API hashes a different header value from the one the app sent | Hash the header value exactly as received |
| Debug tokens reach production | Allow debug tokens is on | Refuse tp.env == "debug", and turn the setting off |