Troubleshooting attestation
For everyone working on attestation: mobile developers, console admins, platform engineers and backend developers. Start from where you see the symptom. This page tells you which component is at fault, the likely cause and the fix, and links to the page with the detail.
Each attestation page has its own troubleshooting table for problems within its component. This page covers the failures that show up in one place but are caused in another.
Find the failing stage
Check the stages in the order of the setup path. The first check that fails tells you where to look:
| Stage | Check | If it fails |
|---|---|---|
| 1. Policy | On the project’s Attestation page, the Policy tab shows the status Active and the published version. An amber dot on Policy means changes that aren’t published yet | Signing and publishing |
| 2. Verifier | attest-verifier check ends with Result: ready to serve (exit 0), and /readyz on port 9090 reports ok | In the verifier’s operation |
| 3. Reachability and pinning | On the Status tab, Verifier checks, Check now for the environment: the row reads pinned and policy vN applied with the published version | On the console’s Status tab |
| 4. App | The SDK’s attestation state reaches ready | In the app |
| 5. API | Your API logs outcome pass, the expected app, and bound true if the app binds | In your API’s log |
Failures that cross components
These are the failures most often met during setup. Each one shows up in one component and is fixed in another.
The app reports verifierUnavailable
- Shows up as: Swift
failed(.verifierUnavailable, …), KotlinFailed(VerifierUnavailable, …), Flutter and React NativeVERIFIER_UNAVAILABLE, every time. Requests go out without the header. On the Status tab, the environment’s row readshost not registeredorpin mismatch. - Cause: the SDKs pin their calls to the verifier, and in strict mode they never reach a host that isn’t registered and pinned in the same TrustPin project. Other causes: the verifier’s certificate was renewed with a new key and the pin wasn’t updated, the pinning instance wasn’t set up before attestation, the verifier URL in the app is wrong, or the verifier is down. On Android, a release build on an emulator or a non-OEM operating system image is refused by the pinning SDK.
- Fix: register and pin the verifier’s domain, then Publish config, and run Check now until Pinned for the SDKs passes. For the other causes, see the SDK page’s troubleshooting: iOS and macOS, Android.
Attestation fails with app_not_allowed
- Shows up as: the app reports
attestationFailedwith the reasonapp_not_allowed(Flutter and React Native:ATTESTATION_FAILED). The verifier answers403 app_not_allowed, in both modes. In monitor mode, a listed Android package signed with an unlisted certificate gets a token withtp.resultfailandapp_not_allowedinstead. - Cause: the policy doesn’t describe the build that runs:
- Apple: the Team ID or Bundle ID differs from the build’s.
- Android: the package name differs, for example a debug build with an
applicationIdSuffix, or the build is signed with a certificate the policy doesn’t list. Builds installed from Google Play are signed with the Play app signing key, not your upload key. - Simulators and emulators: a debug token presents the app ID of the build. On a simulator build without a signing team, set
TRUSTPIN_DEBUG_APP_ID. - The policy change that adds the app was saved but not published, or was published less than one refresh interval (5 minutes) ago.
- The app is configured with a verifier that serves another TrustPin project.
- Fix: compare the policy with the build:
codesign -d --entitlements -on Apple,apksigner verify --print-certson Android. See Apple apps and Android apps, then Sign and publish.
Your API refuses the token: binding mismatch
- Shows up as:
bound to another sessionin your API’sattestation refused:log line, or in the gateway’s log. The app reaches ready and sends the header. - Cause: your API hashes a different value from the one the app bound. Typical causes: the API drops the scheme (
Bearer) before hashing, a proxy or gateway rewrites theAuthorizationheader, or the app uses fixed binding (setTokenBinding) while the API hashesAuthorization. - Fix: hash the header value exactly as received, scheme included, or the value agreed on for fixed binding. See Binding.
A staging token reaches the production API
- Shows up as:
ERR_JWKS_NO_MATCHING_KEY, orERR_JWT_CLAIM_VALIDATION_FAILED unexpected "iss", in your API’s log, for tokens from one app build only. Gateways logunknown kidorwrong iss. - Cause: each environment has its own verifier, with its own signing keys and issuer, while sharing one policy. A build configured with the staging verifier’s URL that calls the production API, or the other way round, presents a token the API can’t verify.
- Fix: point each build at its own environment’s verifier, and give each API the values from the Go-live guide with its own Environment picked. See Staging and production.
The same ERR_JWKS_NO_MATCHING_KEY appears for tokens issued before a restart of a verifier without TRUSTPIN_TOKEN_SIGNING_KEYS, or signed by a key removed before the end of a rotation. See Signing keys.
Debug tokens don’t work, or work where they shouldn’t
A debug token works only when both hold: the published policy has Allow debug tokens on, and the verifier the app calls has the token’s hash in TRUSTPIN_DEBUG_TOKEN_HASHES. Production verifiers have no hashes, so they never accept one.
| Symptom | Likely cause | Fix |
|---|---|---|
On a simulator: Swift unsupportedDevice, Flutter and React Native UNSUPPORTED_DEVICE. On an emulator: Kotlin EvidenceUnavailable or AttestationFailed | No debug token reached the app, or this verifier doesn’t accept debug tokens | Pass the token as the SDK page says, and use the staging verifier. See Staging: debug tokens |
attestationFailed with debug_token_invalid | The token’s hash isn’t in this verifier’s TRUSTPIN_DEBUG_TOKEN_HASHES: it was replaced, or belongs to another environment | Generate a token for this verifier with keygen --debug-token |
attestationFailed with app_not_allowed, with a debug token | The app ID taken from the build isn’t in the policy | See app_not_allowed |
debug token in your API’s log | A debug-token token reached an API without TRUSTPIN_ALLOW_DEBUG=true | Expected on production. On a staging API, set it. Gateway recipes always refuse debug tokens |
| A real Android phone attests with a debug token | A debuggable build with a debug token, and Allow debug tokens on | Build without the token to test device evidence. See Emulators and development |
/readyz reports degraded with debugTokens | TRUSTPIN_DEBUG_TOKEN_HASHES is set | Expected on staging. Remove it anywhere else |
Clocks out of step
- Shows up as:
ERR_JWT_EXPIREDin your API’s log for tokens the app just received; a[warn]on theclockline ofattest-verifier check; a note on Challenge and applied policy in Check now that the verifier’s clock is off; Play Integrity refusals withtoken_too_old. - Cause: your API’s host or the verifier’s host is off. Your API accepts 60 seconds of skew, and
checkwarns beyond 30 seconds. The device’s clock doesn’t matter: the SDKs schedule against the verifier’s clock. - Fix: run NTP on every API and verifier host.
The verifier exits with code 3
- Shows up as: the verifier, or
attest-verifier check, exits with code3. - Cause: no policy is published for the project in
TRUSTPIN_ORG_IDandTRUSTPIN_PROJECT_ID. The first publish must be made in the console; the CLI refuses it. - Fix: on the Policy tab, Sign and publish, then start the verifier again. See Signing and publishing.
Play Integrity credentials are missing
- Shows up as: a restarted verifier exits with code
2, naming the Play Integrity credentials. A running verifier stays on the previous policy version,/readyzreportsdegradedwithpolicy refresh incompatible, and the Status tab row readspolicy vN applied, vM published. - Cause: a policy whose Evidence is Both or Play Integrity only was published before the verifier had
TRUSTPIN_PLAY_INTEGRITY_CREDENTIALS. - Fix: set the credential on every instance of every environment, and restart. Deploy configuration before the policy that needs it. See Policy updates.
With Either, a verifier without the credential keeps working: Android devices pass on Key Attestation alone.
Every device attests again after a deploy
- Shows up as: a spike of attestations, Apple and Google calls and Play Integrity quota after a verifier deploy; refreshes answer
401 invalid_key_certificate. - Cause:
TRUSTPIN_TOKEN_SIGNING_KEYSchanged, was dropped (so the verifier generates keys in memory at each start), or differs between instances. - Fix: restore the previous list, give every instance of the environment the same value, and rotate in three steps.
A policy change doesn’t seem to apply
| Symptom | Likely cause | See |
|---|---|---|
| Nothing changed at all | The change is saved but not published (amber dot on Policy), or verifiers haven’t refreshed yet (5 minutes by default) | Policy updates |
| A tightened device check doesn’t refuse devices | Device checks apply at each device’s next attestation, within one Key certificate lifetime | When changes take effect |
tp.result fail still appears after switching to enforce | Tokens issued in monitor mode stay valid until they expire | Rolling out |
| A verifier keeps an older version | It refuses the new one; its log and /readyz say why | Readiness |
In the app
Read the SDK’s state and turn on debug logging; the SDK page explains how. Error names follow each language:
| Swift | Kotlin | Flutter, React Native | Usually means | Details |
|---|---|---|---|---|
invalidConfiguration | InvalidConfiguration | INVALID_CONFIGURATION | A configuration key is missing or invalid, or the file isn’t in the app | The SDK page’s configuration section |
alreadyInitialized | AlreadyInitialized | ALREADY_INITIALIZED | Setup ran twice. Flutter: expected after a hot restart | The SDK page’s set-up section |
notSetUp | NotSetUp | NOT_SET_UP | A call before setup. React Native: the native start never ran, or the app wasn’t rebuilt | React Native troubleshooting |
unsupportedDevice | UnsupportedDevice | UNSUPPORTED_DEVICE | Apple: App Attest unavailable (simulators without a debug token, some Macs). Both platforms: new keys keep failing on this device | Debug tokens, Support flow |
evidenceUnavailable | EvidenceUnavailable | EVIDENCE_UNAVAILABLE | The policy’s Evidence asks for something this device can’t provide, or Play Integrity without a project number in the app | Android apps |
bindingRequired | BindingRequired | BINDING_REQUIRED | Require token binding is on and no binding is set yet. Expected before sign-in | Bind tokens |
attestationFailed(reasons:) | AttestationFailed(reasons) | ATTESTATION_FAILED | The verifier refused. reasons says why | Reason codes |
verifierUnavailable | VerifierUnavailable | VERIFIER_UNAVAILABLE | The verifier’s domain isn’t registered or pinned, or the verifier can’t be reached | verifierUnavailable |
verifierIncompatible | VerifierIncompatible | VERIFIER_INCOMPATIBLE | The verifier is older than 1.0.0 | Upgrade the verifier; no app change is needed |
invalidDigest | InvalidDigest | INVALID_DIGEST | assertionHeaders got something other than a 32-byte SHA-256 digest | Assertions for sensitive operations |
After a failure, the SDK tries again only after the retry time in the failed state, at the next request, getToken() or prefetch(). A refusal makes the device wait TRUSTPIN_REFUSAL_RETRY_AFTER (10 minutes by default), and that wait survives a relaunch. Once the cause is fixed, reset() ends it.
Requests arrive without the header
The SDKs never block a request: without a token, it goes out without the header. Your API logs outcome missing.
| Likely cause | Check |
|---|---|
The host isn’t in the attested hosts, or the URL is http:// | The configuration’s attested hosts |
| The request doesn’t go through a TrustPin integration | The “Send the header” section of the SDK page: iOS and macOS, Android, Flutter, React Native |
| No token yet: the state is attesting, or failed | The state and the error, in the table above |
| The device can’t attest, or the app version has no attestation | Expected for some requests. Your API decides: see Requests without the header |
Reason codes
Reason codes appear in the app’s attestationFailed reasons, in the verifier’s 403 answers and log, and in tp.reasons of tokens issued in monitor mode. They come in two classes.
Binding failures mean the evidence doesn’t match its device key, challenge or app. They never yield a token, in either mode, and no policy setting relaxes them. A sustained rate fires the AttestVerifierForgeries alert (Alerts and dashboard).
| Code | Meaning | What to do |
|---|---|---|
chain_invalid | The evidence’s certificate chain doesn’t lead to the platform’s root, or a certificate has expired | Nothing for isolated devices. Many at once: contact support with the verifier’s log lines |
chain_revoked | Google has revoked a certificate in the device’s attestation chain | Nothing: the device can’t attest with Key Attestation |
nonce_mismatch, key_mismatch, counter_invalid, request_hash_mismatch, assertion_invalid | The evidence or a signature doesn’t match its challenge, key or request. On a renewal, the SDK discards the key and attests again, once | Nothing for isolated cases |
token_too_old | The Play Integrity verdict is older than Maximum verdict age (seconds) | Check the verifier host’s clock; raise the setting only if slow networks explain it |
debug_token_invalid | The debug token isn’t among this verifier’s hashes | See Debug tokens |
key_not_hardware_generated | The Android key wasn’t generated in secure hardware, as on emulators | Use a debug token on emulators |
payload_mismatch | POST /v1/assertion only: your API’s digest covers different bytes from the app’s | Digest the exact request bytes on both sides. See Assertions |
Policy failures mean the device or app doesn’t meet a setting of your policy. In monitor mode the device still gets a token, with tp.result fail; in enforce mode it gets 403 attestation_failed. Each setting is described in Setting up apps and the policy, and the rollout page says how to judge each reason before enforcing.
| Code | Setting | Typical fix |
|---|---|---|
app_not_allowed | The app lists | app_not_allowed |
dev_environment | Accept development builds | Turn it on while testing builds run from Xcode |
app_not_recognized | Require an app recognized by Google Play | Off while testing builds not installed from Play, or use a Play testing track |
unlicensed | Require a licensed app | Off unless you distribute only through Play |
device_integrity_insufficient | Minimum device integrity | Accept the loss, or lower the level |
app_access_risk, play_protect_risk, device_activity_excessive | App access risk, Play Protect: maximum risk, Recent device activity: maximum level | Measure what each costs in monitor mode first |
verdict_unavailable | Informational: an optional Play Integrity verdict was missing, and the check passed | Turn the response on in Play Console, Change responses |
security_level_insufficient | Minimum security level | STRONG_BOX refuses most devices |
verified_boot_failed, bootloader_unlocked | Require verified boot, Require a locked bootloader | Expected for modified devices; relax only for testing |
patch_level_too_old | Minimum OS patch level (YYYYMM, optional) | Accept the loss, or lower it |
evidence_missing | Evidence | The device didn’t supply the evidence the option requires. Consider Either |
app_identity_unverified | Key Attestation on a device whose bootloader isn’t locked | Expected for such devices |
The SDKs may report their own reason when the verifier gave none: http_<status> for an answer without a reason, invalid_key and invalid_input. too_many_attestations is the verifier’s per-key limit, below.
In the verifier’s answers
The SDK reacts to each error on its own; you see these codes in the verifier’s log, in the trustpin_attest_http_requests_total metric by code, and in the reasons the app reports.
| HTTP | error | What the SDK does | What to check |
|---|---|---|---|
| 400 | bad_request | Reports it, and waits as after a refusal | The verifier log’s message. A Play Integrity token Google rejected answers “play integrity token rejected by Google” |
| 400 | binding_required | Reports bindingRequired | Expected before sign-in with Require token binding on |
| 401 | invalid_challenge | Fetches a new challenge and retries once | Repeated: instances with different TRUSTPIN_CHALLENGE_SECRETS, or a host clock that is off |
| 401 | invalid_key_certificate | Discards its key and attests again | Expected once after a stricter policy (an app or digest removed, the switch to enforce). Repeated: instances with different TRUSTPIN_TOKEN_SIGNING_KEYS |
| 403 | attestation_failed | Reports the reasons and waits | Reason codes |
| 403 | app_not_allowed | Reports it and waits | app_not_allowed |
| 409 | challenge_reused | Starts again with a new challenge | Repeated: a gateway or load balancer that retries POST requests to the verifier |
| 413 | payload_too_large | Reports it and waits | Rare. Send the verifier’s log line to support |
| 429 | rate_limited | Waits for Retry-After | Your gateway’s limit, or TRUSTPIN_RATE_LIMIT. With TRUSTPIN_RATE_LIMIT set and no TRUSTPIN_TRUSTED_PROXIES, every app shares the gateway’s address and one limit |
| 429 | too_many_attestations | Keeps its key and waits | A device key over TRUSTPIN_KEY_RATE_LIMIT (60 an hour). Look for an app loop calling reset() or assertionHeaders |
| 502 | upstream_unavailable | Backs off and retries | The verifier can’t reach Play Integrity, or its credentials are refused. Existing tokens stay valid |
| 503 | not_ready | Backs off and retries | No policy loaded yet, or the store is down. See Readiness |
| 404 | No TrustPin error body | The attempt fails | The gateway forwards the URL’s path to the verifier: strip it. See Route HTTPS to it |
401 invalid_token comes only from POST /v1/assertion, which your API calls; see Assertions for sensitive operations. The full list is in the verifier API reference.
In the verifier’s operation
Exit codes
attest-verifier check and the verifier exit with the same codes. check prints a [FAIL] line for each problem.
| Exit code | Usually means | Fix |
|---|---|---|
1 | The CDNs are unreachable and there is no cached policy; the policy fails validation because TRUSTPIN_PROJECT_PUBLIC_KEY belongs to another project; or the store is unreachable or its schema is behind | Deploying the verifier, troubleshooting |
2 | A configuration error: quotes passed by docker run --env-file, an example placeholder left in, or Play Integrity credentials the policy needs | Play Integrity credentials, Fill in and check the configuration |
3 | No policy is published for the project | Exit code 3 |
Read every [warn] line too. The usual ones: the clock is more than 30 seconds off, the signing keys or challenge secrets are generated in memory, the store is memory:// with several instances, or an unknown TRUSTPIN_* variable, usually a misspelling.
Readiness
GET /readyz on port 9090 answers ok, degraded (serving, but needs attention) or unavailable (no traffic). Send the monitor key to see which check fails.
| Check | Status | Usually means |
|---|---|---|
policy | unavailable | No policy loaded |
store | unavailable | Redis or Postgres unreachable: /v1/attest and /v1/token answer 503 not_ready |
policy | degraded | policy refresh incompatible: Play Integrity credentials. not_found: the policy is no longer published. Three failed refreshes: the outbound path to the CDN |
signingKeys | degraded | TRUSTPIN_TOKEN_SIGNING_KEYS is unset: every device attests again at each restart |
debugTokens | degraded | Debug token hashes are set: expected on staging only |
revocationList | degraded | No outbound HTTPS to android.googleapis.com for 7 days |
The causes and fixes for each are in Readiness. If probes from outside the container can’t connect at all, set TRUSTPIN_MONITOR_LISTEN_ADDR=:9090 with TRUSTPIN_MONITOR_API_KEY.
On the console’s Status tab
Check now, under Verifier checks, connects to the environment’s verifier URL as an app would. Each step passes, warns or fails, and the environment’s row sums them up.
| Step | Failure | Fix |
|---|---|---|
| Reachable over TLS | The connection fails, or the certificate expires soon | Check DNS, TLS and the firewall for the environment’s URL. When you renew the certificate with a new key, update the pin |
| Pinned for the SDKs | host not registered, pin mismatch, or every pin has expired | Register and pin the domain. Apps in strict mode report verifierUnavailable meanwhile |
| Issuer | Issuer mismatch | Set TRUSTPIN_TOKEN_ISSUER to the environment’s URL, or fix the URL on the Deployment tab. Your API’s Issuer (iss) must match too |
| Issuer | Discovery is not served | The issuer isn’t an https:// URL. Gateways that find keys through OIDC discovery can’t use this verifier |
| Signing keys | The JWKS can’t be fetched, or publishes no key | Route /.well-known/ to the verifier |
| Challenge and applied policy | The verifier applies no policy yet (not_ready) | Check TRUSTPIN_ORG_ID, TRUSTPIN_PROJECT_ID and TRUSTPIN_PROJECT_PUBLIC_KEY, and the verifier’s log |
| Challenge and applied policy | policy vN applied, vM published | Wait one refresh interval. If it stays behind, the verifier refuses the new version: see Readiness |
| Challenge and applied policy | The applied version is ahead of the published one | The URL reaches a verifier of another project, or one that loads its policy from another source (TRUSTPIN_ATTEST_CONFIGURATION_URL) |
| Challenge and applied policy | A note that the clock is off, or that debug tokens are accepted | Clocks; on a production environment, remove TRUSTPIN_DEBUG_TOKEN_HASHES |
| Any step | The verifier redirected the request | Apps and TrustPin follow no redirects. Point the environment’s URL at the final address |
The Verifier fleet card reads Never fetched or No recent fetch when no policy fetch has been logged. Fetch logs arrive a few hours late, per UTC day, so a verifier deployed today can take a while to appear. Check now gives the exact state at once.
In your API’s log
The checkAttestation middleware logs one attestation line per request, with outcome, and an attestation refused: line when a token fails verification. A gateway recipe logs trustpin attestation rejected: <reason>.
| Log | Likely cause | Fix |
|---|---|---|
outcome missing | The request had no header | Requests arrive without the header |
ERR_JWKS_NO_MATCHING_KEY | A token from another environment’s verifier, or signed by a key the verifier no longer has | A staging token reaches the production API |
ERR_JWT_CLAIM_VALIDATION_FAILED unexpected "iss" or "aud", or wrong project | The API’s issuer, audience or project differ from the verifier’s. A verifier without a public URL issues tprn::attest::… | Copy the values from the Go-live guide for the right environment |
ERR_JWT_CLAIM_VALIDATION_FAILED unexpected "typ" | Something other than an access token was sent in the header | Send the token from the SDK only |
ERR_JWT_EXPIRED | The token expired, or the clocks differ by more than 60 seconds | Clocks |
ERR_JWKS_TIMEOUT or a fetch error | The API can’t reach the JWKS URL | curl the JWKS URL from the API’s host |
bound to another session | Binding mismatch | Binding mismatch |
unbound token | Require-binding is on in the API, and the app doesn’t bind | Bind in the app, or turn require-binding off while Require token binding is off |
debug token | A debug-token token on an API without TRUSTPIN_ALLOW_DEBUG=true | Debug tokens |
outcome fail | A token issued in monitor mode, with reasons | Reason codes |
outcome pass, but env development or debug on production | The policy accepts development builds or debug tokens, which every environment shares | Prepare for enforce |
The full table is in Verifying tokens in your API.
At the gateway
The gateway recipes check the same things as your API, with these differences, which explain most surprises:
| Symptom | Cause | See |
|---|---|---|
| Requests are refused while the policy is in monitor mode | The recipes refuse every request without the header, whatever the mode | Gateway or API |
| The app can’t get a token once a recipe is deployed | The recipe sits in front of the verifier’s own routes | What every recipe checks |
| Simulator and emulator builds are refused | The recipes always refuse debug tokens | What every recipe checks |
Every token is refused as wrong project on Envoy | PROJECT in the Lua file still holds the placeholder | Envoy |
| Tokens are refused just after a key rotation | The gateway’s JWKS cache doesn’t know the new key yet | Signing keys |
AWS answers 401 to a request with a token | The request has no Authorization header | AWS API Gateway |
The rest is in Checking tokens at the gateway.
Before you contact support
TrustPin sees no attestation traffic, so the answers are in your own logs. Collect these before writing to support@trustpin.cloud:
- the verifier version (
attest-verifier version), the output ofattest-verifier check, and the/readyzbody; - the Check now result for the environment;
- the app’s SDK and version, its state and error, with the reasons, and the time of the attempt;
- the verifier’s log lines for that time (they carry
request_idand the first characters of the device key, never tokens or evidence), and your API’sattestationline.
Never send a valid token, a debug token or a secret.
Next steps
- Attestation FAQ
- Operating the verifier: metrics, alerts and incident response
- Rolling out: monitor to enforce