App attestation (Flutter)
For Flutter developers adding attestation to an app that already uses TrustPin pinning (trustpin_sdk). At the end of this page, your requests carry a token your backend can verify.
How it works
trustpin_attest is a thin wrapper of the native attestation modules, TrustPinAttestKit on iOS and macOS and kotlin-attest on Android. 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. trustpin_sdk’s HTTP integrations add the token to requests for the hosts you choose as the TrustPin-Attestation header, and 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 |
|---|---|
| Flutter | 3.44.0 or later. iOS and macOS through Swift Package Manager only |
| iOS, macOS | iOS 15 or later, macOS 13 or later, with the App Attest capability |
| Android | API 25 or later, Kotlin 2.3.0 or later |
trustpin_sdk | Set up in your app, at the same version as trustpin_attest |
| 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 |
SDK and verifier compatibility
| SDK | Verifier |
|---|---|
trustpin_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 and macOS, 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 and macOS: add the App Attest capability to the Runner target. Its entitlement is
developmentfor builds run from Xcode andproductionfor TestFlight and the App Store. - 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 by the
TrustPininstance with the same identifier, and in strict mode an unregistered host is never reached.
Install
dependencies:
trustpin_sdk: ^7.1.0
trustpin_attest: ^7.1.0Both packages must be on the same version. They bring the native 7.1.x modules with them.
Configure
Add the attestation keys to the configuration files the pinning setup reads.
iOS and macOS, 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.
- The verifier URL must be
https://on a host name, without credentials, query or fragment. IP addresses are refused. - Attested hosts: one to fifty host names, each exact or
*.example.comfor exactly one label. Only these hosts ever receive the header.
You can also configure in code with TrustPinAttestConfiguration and setup.
Set up
Once per launch, after pinning:
import 'package:trustpin_attest/trustpin_attest.dart';
import 'package:trustpin_sdk/trustpin_sdk.dart';
await TrustPin.shared.setupWithNativeBundle();
await TrustPinAttest.shared.setupWithNativeBundle();
await TrustPinAttest.shared.bindToHeader('Authorization');
await TrustPinAttest.shared.prefetch();setup makes no verifier call: it validates the configuration and loads a device key from an earlier launch. A second call throws ALREADY_INITIALIZED. prefetch() never throws.
Named instances pair with pinning instances of the same id: TrustPinAttest.instance('id') with TrustPin.instance('id'), which must be set up first.
Bind tokens to the user’s session
bindToHeader('Authorization')binds each token to the value of that request header.setTokenBinding(value)binds to a fixed value, such as a session id.
Each clears the other. Only a SHA-256 of the value leaves the device. 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, and attestation resumes as soon as a binding is set.
Send the header
trustpin_sdk’s HTTP integrations add the header automatically to HTTPS requests for the attested hosts:
// package:http
final client = TrustPinHttpClient.create();
// Dio: pass the instance so redirects of attested requests are followed
final dio = Dio();
dio.interceptors.add(TrustPinDioInterceptor(dio: dio));- A header the request already carries is never replaced.
- A redirect never carries the header to another host: the integrations follow redirects of attested
GETandHEADrequests themselves, pinning each hop and asking for its own header. Without thedioinstance, the redirect response reaches your code. - For other HTTP clients,
TrustPin.shared.attestationHeaders(uri, headers: …)returns the headers to add, andredirectedRequestHeaders(…)the headers for a redirect hop. - For non-HTTP transports (gRPC, WebSockets), send
await TrustPinAttest.shared.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:
import 'package:crypto/crypto.dart';
final body = utf8.encode(jsonEncode({'amount': '1.00', 'to': 'demo'}));
final headers = {'Authorization': 'Bearer $session'};
headers.addAll(await TrustPinAttest.shared
.assertionHeaders(Uint8List.fromList(sha256.convert(body).bytes)));
await client.post(url, headers: headers, body: body);assertionHeaders returns both headers, TrustPin-Attestation with the token and TrustPin-Assertion with the 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. A digest that isn’t 32 bytes throws 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:
const debugToken = String.fromEnvironment('TRUSTPIN_DEBUG_TOKEN');
await TrustPinAttest.shared.setupWithNativeBundle(
debugToken: debugToken.isEmpty ? null : debugToken,
);flutter run --dart-define=TRUSTPIN_DEBUG_TOKEN=<token>- It is used on the iOS simulator and in debuggable Android builds, never on iOS devices or macOS, and never sent in release builds.
- Debug tokens carry
tp.envdebug, which production APIs refuse.
State, errors and logging
TrustPinAttest.shared.stateChanges.listen((state) {
switch (state) {
case TrustPinAttestUnavailable():
print('No token yet');
case TrustPinAttestAttesting():
print('Attesting…');
case TrustPinAttestReady(:final expiresAt):
print('Token ready until $expiresAt');
case TrustPinAttestFailed(:final error, :final retryAt):
print('Failed: ${error.code}, next attempt after ${retryAt ?? 'restart'}');
}
});stateChanges emits the current state first, then every change. After a failure no timer runs: nothing is tried before retryAt, and the next request, getToken() or prefetch() after it starts a new attempt. A null retryAt means no new attempt in this launch, until reset() or a restart, except BINDING_REQUIRED, which resumes once a binding is set.
Errors are TrustPinAttestException 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 was called again | Set up once per instance |
NOT_SET_UP | Called before setup | Call setup first |
UNSUPPORTED_DEVICE | App Attest is unsupported on this device (iOS, macOS) | 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(TrustPinAttestLogLevel.debug) in development, and TrustPinAttest.logs to route messages. Log lines never contain tokens, key certificates, bound values or debug tokens.
Events
TrustPinAttest.shared.events delivers TrustPinAttestEvent values 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.
TrustPinAttest.shared.events.listen((event) {
if (event case TrustPinAttestAttestationSucceeded(:final duration)) {
metrics.record('attestation', duration);
}
});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
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 client isn’t TrustPinHttpClient or TrustPinDioInterceptor, or there is no token yet | Check the host list and the client, then turn on debug logging |
ATTESTATION_FAILED with app_not_allowed | The app or its signing certificate is not in the policy | Add them |
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 |
iOS: UNSUPPORTED_DEVICE on the simulator | No debug token | Pass --dart-define=TRUSTPIN_DEBUG_TOKEN=… |
| A redirect with Dio returns the 3xx | TrustPinDioInterceptor was created without dio | Pass the Dio instance |
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 and macOS 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.