Skip to Content
App AttestationVerifying tokens in your API

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": [] } }
ClaimMeaning
issThe verifier’s issuer: by default tprn::attest::<organization id>::<project id>, or its public URL when discovery is on
audThe project audience: by default tprn::project::<organization id>::<project id>
subThe 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, jtiIssue and expiry times, and a random ID
bindPresent only when the app bound the token to a session value; the digest of that value
tp.prjThe project, tprn::project::<organization id>::<project id>
tp.platapple, android or debug
tp.app<teamId>.<bundleId> on Apple, the package name on Android
tp.evAn array of the evidence that was verified: app_attest, key_attestation, play_integrity, debug_token
tp.envproduction, development (Xcode-run builds, when the policy allows them) or debug (debug tokens)
tp.pvPolicy version the decision was made under
tp.mode, tp.result, tp.reasonsThe policy outcome; see Monitor and enforce

Verification steps

  1. Fetch and cache GET <verifier>/.well-known/jwks.json. Refetch when a token carries an unknown kid, at most once every 30 s, with a 5 s fetch timeout.
  2. Reject any alg other than ES256 and any typ other than trustpin-attest+jwt. The type check also rejects key certificates.
  3. Check the signature against the JWKS key named by kid.
  4. Check iss, aud and exp, allowing at most 60 seconds of clock skew, and that tp.prj equals tprn::project::<organization id>::<project id>. Refuse a token without it.
  5. If you use binding: require bind and compare it with base64url(SHA-256(<the agreed value>)), unpadded, over the header value exactly as received. With the SDKs’ bindToHeader("Authorization"), the agreed value is the request’s Authorization header. With Require token binding on, turn on the require-binding option so a token without bind is refused.
  6. Reject tp.env == "debug" outside development environments.
  7. 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 modeWhat the token saysWhat your API should do
EnforceNew 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 themReject requests without a valid token on the endpoints you protect. If you need an immediate cut-off, reject tp.result == "fail" yourself
Monitortp.result may be fail, with tp.reasons listing the policy checks that failedLog 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

SymptomLikely causeFix
Every token is refused on iss, aud or tp.prjThe API expects a different issuer, audience or project from the verifier’sCompare them with TRUSTPIN_TOKEN_ISSUER and TRUSTPIN_TOKEN_AUDIENCE
Tokens are refused as expired or not yet validClock skewSynchronise the API’s and the verifier’s clocks with NTP
Binding mismatchThe API hashes a different header value from the one the app sentHash the header value exactly as received
Debug tokens reach productionAllow debug tokens is onRefuse tp.env == "debug", and turn the setting off

Next steps