App attestation (React Native)
For React Native developers adding attestation to an app that already uses TrustPin pinning (@trustpin/react-native). At the end of this page, your requests carry a token your backend can verify.
How it works
@trustpin/react-native-attest is a thin wrapper of the native attestation modules, TrustPinAttestKit on iOS and kotlin-attest on Android. Like pinning, it starts in native code before JavaScript runs. The device creates a key and sends evidence about the app and the device to the attestation verifier you run: Apple App Attest, or Android Key Attestation and Google Play Integrity, as your policy asks. The verifier answers with a short-lived signed token, which the SDK renews cheaply with the same key.
React Native’s networking is native, so fetch, XMLHttpRequest and axios carry the TrustPin-Attestation header to the hosts you choose with nothing to change. Your backend verifies it and decides what an unattested request may do.
The SDK never blocks or fails your requests. When no token is available in a short, bounded time, the request goes out without the header.
Requirements
| Item | Requirement |
|---|---|
| React Native | 0.85 or later (New Architecture only) |
@trustpin/react-native | Set up in your app, at the same version as @trustpin/react-native-attest |
| iOS | 15 or later, with the App Attest capability |
| Android | API 25 or later, Kotlin 2.3.0 or later |
| Verifier | Deployed, reachable over HTTPS, with a published policy |
| Devices | iOS: a device that supports App Attest. Android: hardware-backed key attestation, and Google Play only if your policy asks for Play Integrity |
Expo Go cannot run attestation, it is native code: use a development build.
SDK and verifier compatibility
| SDK | Verifier |
|---|---|
@trustpin/react-native-attest 7.1.0 | Contract version 2 or newer: verifier 1.0.0 or later |
With an older verifier the state is failed with VERIFIER_INCOMPATIBLE and no header is sent until the verifier is upgraded. The app recovers on its own at the next attempt, with no update or reinstall.
Before you write code: the policy
Your verifier checks every attestation against a signed policy, which you manage on the project’s Attestation page in the TrustPin console . List each platform’s app: the Team ID and Bundle ID for iOS, the Package name and every Signing certificate SHA-256 for Android. The settings are described on Setting up apps and the policy, and the Android evidence options on the Android page.
- iOS: the App Attest capability. Its entitlement is
developmentfor builds run from Xcode andproductionfor TestFlight and the App Store. The Expo plugin adds it. - Android with Play Integrity: you need the number of the Google Cloud project linked to the app in Play Console.
- Register your verifier’s domain in your TrustPin project. Calls to the verifier are pinned, and in strict mode an unregistered host is never reached.
Install
npm install @trustpin/react-native @trustpin/react-native-attestBoth packages must be on the same version. They bring the native 7.1.x modules with them.
Setup with Expo
List the attestation plugin with the pinning one:
{
"expo": {
"plugins": [
["@trustpin/react-native", { "organizationId": "…", "projectId": "…", "publicKey": "…" }],
["@trustpin/react-native-attest", {
"verifierUrl": "https://attest.example.com",
"attestedHosts": ["api.example.com"],
"appAttestEnvironment": "development"
}]
]
}
}npx expo prebuild adds the attestation keys to the configuration files the pinning plugin writes, adds the App Attest entitlement, and starts attestation right after pinning on both platforms.
| Prop | Type | Notes |
|---|---|---|
verifierUrl | string | Your verifier, https:// on a host name, without credentials, query or fragment. Set it with attestedHosts, or neither when your pinning configFiles already carry the keys |
attestedHosts | string[] | The hosts that receive the header: exact names, or *.example.com for exactly one label. No other host ever receives it |
googleCloudProjectNumber | string | Android, when the policy uses Play Integrity |
appAttestEnvironment | development | production | The iOS entitlement’s value. Defaults to production |
logLevel | none | error | warning | info | debug | Passed to the native init helper |
Setup with bare React Native
1. Add the attestation keys to the files the pinning setup reads.
iOS, TrustPin-Info.plist:
<key>AttestVerifierURL</key>
<string>https://attest.example.com</string>
<key>AttestedHosts</key>
<array>
<string>api.example.com</string>
</array>Android, android/app/src/main/assets/trustpin.json:
"attest_verifier_url": "https://attest.example.com",
"attested_hosts": ["api.example.com"]Add "google_cloud_project_number" only when your policy uses Play Integrity: with a number, the app contacts Google Play services from setup on.
2. Start attestation right after pinning, in native code.
iOS, AppDelegate.swift:
import TrustPinReactNative
import TrustPinReactNativeAttest
TrustPinReactNative.start()
TrustPinReactNativeAttest.start() // or: .start(logLevel: .debug)Android, MainApplication.kt:
import cloud.trustpin.reactnative.TrustPinReactNative
import cloud.trustpin.reactnative.attest.TrustPinReactNativeAttest
override fun onCreate() {
TrustPinReactNative.start(this)
TrustPinReactNativeAttest.start(this) // or: .start(this, TrustPinAttestLogLevel.DEBUG)
super.onCreate()
loadReactNative(this)
}Then cd ios && pod install, and rebuild. Setup makes no verifier call: it validates the configuration and loads a device key from an earlier launch. A failure never crashes the app; it shows in the log and the state, and requests go out without the header.
Bind tokens to the user’s session
import TrustPinAttest from '@trustpin/react-native-attest';
await TrustPinAttest.bindToHeader('Authorization');
TrustPinAttest.prefetch();Each token is then bound to the request’s Authorization value: only its SHA-256 travels, and a token taken from one session is useless with another. setTokenBinding(value) binds to a fixed value instead. A binding change renews the token with the existing device key; it is not a new attestation.
If your policy has Require token binding on, no token exists until a binding is known: the state is failed with BINDING_REQUIRED and no retry time. That is expected before sign-in.
Send the header
Nothing to change. fetch, XMLHttpRequest and axios requests to the attested hosts carry the header, added natively.
- A header the request already carries is kept.
- A redirect never carries the header to another host: each redirect gets the header for its own URL only.
- For other HTTP clients,
TrustPinAttest.attestationHeaders(url, { headers })returns the headers to add. - For transports React Native’s networking doesn’t carry (gRPC, some WebSocket clients), send
await TrustPinAttest.getToken()in theTrustPin-Attestationheader.
Sensitive operations
For an operation that must come from this device, such as a payment, send an assertion next to the token. It binds the device’s attested key to a SHA-256 digest of your choice, typically the request body:
const body = JSON.stringify({ amount: '1.00', to: 'demo' });
const headers = { Authorization: `Bearer ${session}`, 'Content-Type': 'application/json' };
Object.assign(headers, await TrustPinAttest.assertionHeaders(sha256(body))); // 32 bytes
await fetch(url, { method: 'POST', headers, body });assertionHeaders takes the digest as 32 bytes (Uint8Array or ArrayBuffer) or 64 hex characters, and returns both headers: the token and TrustPin-Assertion. Each call produces a new assertion: one per operation. Your backend passes both headers and its own digest to your verifier. See Assertions for sensitive operations. React Native has no built-in digest API; use your crypto library of choice. Any other digest rejects with INVALID_DIGEST.
Verify the token in your API
The header carries a signed JWT issued by your verifier. Your API checks its signature against the verifier’s published keys, its type, issuer, audience, expiry and project, and its binding, and refuses debug tokens in production. See Verifying tokens in your API.
Simulators, emulators and development
Simulators and emulators cannot provide evidence your policy accepts. For development, your verifier’s operator can issue a debug token. The policy (Allow debug tokens) and the verifier must both allow it. Pass it from the build, never from a file:
- iOS simulator: set
TRUSTPIN_DEBUG_TOKENin the Xcode scheme’s environment, or passTrustPinReactNativeAttest.start(debugToken:)from a debug-only build setting. Ignored on devices. - Android: pass
TrustPinReactNativeAttest.start(this, level, debugToken)from a debug-onlyBuildConfigfield, as on the Android page. Used only in debuggable builds.
Debug tokens carry tp.env debug, which production APIs refuse.
State, errors and logging
const subscription = TrustPinAttest.onStateChange(state => {
switch (state.status) {
case 'unavailable': return console.log('No token yet');
case 'attesting': return console.log('Attesting…');
case 'ready': return console.log('Token ready until', new Date(state.expiresAtMs));
case 'failed': return console.log('Failed:', state.error.code, state.retryAtMs ?? 'after restart');
}
});onStateChange delivers the current state first, then every change. After a failure no timer runs: nothing is tried before retryAtMs, and the next request, getToken() or prefetch() after it starts a new attempt. A null retryAtMs means no new attempt in this launch, until reset() or a restart, except BINDING_REQUIRED, which resumes once a binding is set.
Errors are TrustPinAttestError with a stable code:
| Code | Meaning | What to do |
|---|---|---|
INVALID_CONFIGURATION | A configuration value was rejected, or the file is missing | Fix the configuration |
ALREADY_INITIALIZED | Setup ran twice | Start once, natively |
NOT_SET_UP | The native init helper never ran | Add TrustPinReactNativeAttest.start(), or the Expo plugin |
UNSUPPORTED_DEVICE | App Attest is unsupported on this device (iOS) | Expected on some hardware |
EVIDENCE_UNAVAILABLE | The device can’t produce the evidence the policy requires | Review Evidence, or configure the project number |
BINDING_REQUIRED | The policy requires a binding and none is set yet | Expected before sign-in |
ATTESTATION_FAILED | The verifier refused; see reasons | Read reasons, such as app_not_allowed |
VERIFIER_UNAVAILABLE | The verifier couldn’t be reached, including when strict pinning refuses its host | Check connectivity and the verifier. In strict mode, register its domain |
VERIFIER_INCOMPATIBLE | The verifier is older than contract version 2 | Upgrade the verifier |
INVALID_DIGEST | assertionHeaders got a digest that isn’t 32 bytes | Hash the payload with SHA-256 first |
New codes can appear in a minor release: handle unknown ones.
Logging
Logging is off by default: setLogLevel('debug') in development, and onLogEvent to route messages. Log lines never contain tokens, key certificates, bound values or debug tokens.
Events
TrustPinAttest.onEvent(listener) delivers events for your metrics: keys generated or replaced, attestations and renewals with their durations, refusals and waits with their end, unsupported devices, an unreachable verifier, and assertions. Events from before JavaScript was running replay to the first subscriber.
TrustPinAttest.onEvent(event => {
if (event.type === 'attestationSucceeded') metrics.timing('attest', event.durationMs);
});Events never carry a token, a key or a device identifier. New event types can appear in a minor release: keep a default case.
Reset
TrustPinAttest.reset() discards the device key, so the next request attests a new key and the token’s sub changes. Use it for support flows, not routinely. It does nothing before setup.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Requests arrive without the header | The host is not in the attested hosts, the URL is http://, the native init helper never ran, or there is no token yet | Check the host list and the native setup, then turn on debug logging |
Every call rejects with NOT_SET_UP | TrustPinReactNativeAttest.start() isn’t called, or the app wasn’t rebuilt | Add it after the pinning start (or the Expo plugin) and rebuild |
ATTESTATION_FAILED with app_not_allowed | The app or its signing certificate is not in the policy | Add them. Android builds from Play use the Play app signing key |
Failed with VERIFIER_UNAVAILABLE every time | The verifier’s domain is not registered, the verifier is down, or its URL is wrong | Register the domain, then check the URL and the verifier |
Privacy
Evidence and tokens go only to your own verifier, nothing to TrustPin. Device keys and key certificates are never backed up or synced: a restored app attests again. On iOS the native framework ships a privacy manifest; on Android, with a Google Cloud project number configured, declare your use of the Play Integrity API in Play Console’s Data safety form.