Skip to Content

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

ItemRequirement
Flutter3.44.0 or later. iOS and macOS through Swift Package Manager only
iOS, macOSiOS 15 or later, macOS 13 or later, with the App Attest capability
AndroidAPI 25 or later, Kotlin 2.3.0 or later
trustpin_sdkSet up in your app, at the same version as trustpin_attest
VerifierDeployed, reachable over HTTPS, with a published policy
DevicesiOS: 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

SDKVerifier
trustpin_attest 7.1.0Contract 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 development for builds run from Xcode and production for 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 TrustPin instance 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.0

Both 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.com for 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 GET and HEAD requests themselves, pinning each hop and asking for its own header. Without the dio instance, the redirect response reaches your code.
  • For other HTTP clients, TrustPin.shared.attestationHeaders(uri, headers: …) returns the headers to add, and redirectedRequestHeaders(…) the headers for a redirect hop.
  • For non-HTTP transports (gRPC, WebSockets), send await TrustPinAttest.shared.getToken() in the TrustPin-Attestation header.

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.env debug, 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:

CodeMeaningWhat to do
INVALID_CONFIGURATIONA configuration value was rejected, or the file is missingFix the configuration
ALREADY_INITIALIZEDsetup was called againSet up once per instance
NOT_SET_UPCalled before setupCall setup first
UNSUPPORTED_DEVICEApp Attest is unsupported on this device (iOS, macOS)Expected on some hardware
EVIDENCE_UNAVAILABLEThe device can’t produce the evidence the policy requiresReview Evidence, or configure the project number
BINDING_REQUIREDThe policy requires a binding and none is set yetExpected before sign-in
ATTESTATION_FAILEDThe verifier refused; see reasonsRead reasons, such as app_not_allowed
VERIFIER_UNAVAILABLEThe verifier couldn’t be reached, including when strict pinning refuses its hostCheck connectivity and the verifier. In strict mode, register its domain
VERIFIER_INCOMPATIBLEThe verifier is older than contract version 2Upgrade the verifier
INVALID_DIGESTassertionHeaders got a digest that isn’t 32 bytesHash 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

SymptomLikely causeFix
Requests arrive without the headerThe host is not in the attested hosts, the URL is http://, the client isn’t TrustPinHttpClient or TrustPinDioInterceptor, or there is no token yetCheck the host list and the client, then turn on debug logging
ATTESTATION_FAILED with app_not_allowedThe app or its signing certificate is not in the policyAdd them
Failed with VERIFIER_UNAVAILABLE every timeThe verifier’s domain is not registered, the verifier is down, or its URL is wrongRegister the domain, then check the URL and the verifier
iOS: UNSUPPORTED_DEVICE on the simulatorNo debug tokenPass --dart-define=TRUSTPIN_DEBUG_TOKEN=…
A redirect with Dio returns the 3xxTrustPinDioInterceptor was created without dioPass 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.

Next steps