appattest_flutter

Flutter bridge for AppAttest — App-Attest-gated secret delivery for iOS.

Ships in lockstep with the Swift SDK (current: v0.4.0).

Platform support

  • iOS 17+ — full support. Uses Apple's DCAppAttestService via the native AppAttest Swift SDK.
  • Android / other — not supported. App Attest is iOS-only; the plugin registers no implementation on other platforms.

Install

# pubspec.yaml
dependencies:
  appattest_flutter: ^0.4.0

Then:

flutter pub get
cd ios && pod install && cd ..

The iOS side depends on the AppAttest pod (the core Swift SDK), wired automatically through the plugin's podspec.

Quick start

import 'package:appattest_flutter/appattest_flutter.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  // `release` is required — see "Buckets and columns" below.
  AppAttest.start(release: ReleaseBucket.production);
  runApp(const MyApp());
}
// Anywhere:
await AppAttest.waitForReady();
final key = await AppAttest.secret('OPENAI_API_KEY'); // String?
final all = await AppAttest.allSecrets();             // Map<String, String>

start(release:) is fire-and-forget: the first launch attests the device once (persists across launches), then syncs secrets; later launches hydrate from the Keychain and re-sync in the background. Foreground re-entry re-syncs automatically — your app does no lifecycle wiring.

State

final s = await AppAttest.getState(); // AppAttestState(name, error?)

final sub = AppAttest.stateStream.listen((s) {
  debugPrint('appattest: ${s.name}');
});
// later: sub.cancel();

AppAttestStateName: initializing, attesting, syncing, ready, subscriptionRequired, creditsRequired, unavailable. The non-ready terminal states carry state.error.

End-user-facing apps: show a generic "temporarily unavailable" notice for the non-ready terminal states. Developer / staff builds: log the full error (including actionUrl) so the developer knows whether to subscribe, top up, or investigate.

Refresh & recovery

await AppAttest.retry();            // re-run the sync (no re-attestation)
await AppAttest.invalidateBundle(); // drop the cached bundle, force a fresh sync
await AppAttest.reset();            // full wipe; next start(release:) re-attests

retry() recovers from transient failures. invalidateBundle() forces fresh secret bytes when you don't want to wait for the next rotation pickup. reset() is the nuclear option, for sign-out / data-clearing flows.

Debug mode (simulator, tests, CI)

The simulator can't produce a real App Attest attestation. Use local stubs:

import 'package:flutter/foundation.dart';

if (kDebugMode) {
  await AppAttest.setDebugMode(DebugMode.local, {
    'OPENAI_API_KEY': 'sk-test-stub',
  });
}
AppAttest.start(release: ReleaseBucket.staging);

DebugMode has a single case, local; pass null to return to real attestation. The native debug surface is #if DEBUG-gated — physically absent from Release builds, which always run real attestation; calling it there throws debug_mode_release_blocked.

Dev builds on real devices don't need debug mode — they attest for real and read the sandbox column (below).

Buckets and columns

Two independent axes. Keeping them apart is the whole model.

1. Which server bucket you declare — you choose it, explicitly.

release is a required argument on AppAttest.start(release:). There is no default, and no inference from kDebugMode or build flavor. The bucket is exactly what you pass:

AppAttest.start(release: ReleaseBucket.production); // shipping build
AppAttest.start(release: ReleaseBucket.staging);    // pre-ship verification

ReleaseBucket.staging and ReleaseBucket.production are two functionally-identical, separately-keyed, metered buckets. Neither is free.

2. Which secrets column Apple puts you in — the AAGUID decides, not you.

Apple's App Attest AAGUID is a build-time property stamped into every attestation, and you cannot forge it:

  • Development-signed builds (Xcode → device) → sandbox column.
  • Distribution builds carrying the production App Attest entitlement (TestFlight, App Store) → production column.

The two axes are orthogonal: both servers have both columns. Edge resolves your declared bucket against the AAGUID. A development-signed build declaring ReleaseBucket.production is rejected with a loud 403 bucket_not_permitted — never silently re-routed.

For last-mile verification of production secrets before submitting to the App Store, use TestFlight: it carries the real production AAGUID, so it reads the production column. There is no debug-build path to the production column.

Error handling

Failures throw AppAttestError (code and message, plus subscribeUrl / topupUrl / actionUrl on the billing cases):

try {
  await AppAttest.waitForReady();
} on AppAttestError catch (e) {
  if (e.code == ErrorCode.subscriptionRequired) {
    debugPrint('project needs a subscription: ${e.actionUrl}');
  }
}
Code Meaning
subscription_required Project subscription not active (subscribeUrl).
credits_required Allowance exhausted and balance empty (topupUrl).
attestation_rejected Apple or AppAttest rejected this install — terminal until reinstall.
service_unavailable Temporary service condition; retryable (the SDK backs off automatically).
network Device-side transport failure; retryable.
debug_mode_release_blocked setDebugMode called in a Release build.
invalid_argument Malformed call input.

(Compare via the ErrorCode constants — ErrorCode.subscriptionRequired etc.; the values are the snake_case strings above.)

License

MIT © 2026 Bault LLC. See LICENSE.

Libraries

appattest_flutter
AppAttest — Flutter bridge for iOS-only App-Attest-gated secret delivery.