+1 (415) 480-3939

Hardening a Flutter App: Secrets, Secure Storage, Pinning, and Device Attestation

Most Flutter apps we are asked to review are functionally fine and security-naive. The API key is in the Dart source, the session token is in SharedPreferences, the release build ships with debug logging, and nobody has looked at what happens when the app runs on a rooted phone behind an intercepting proxy. That is usually discovered late — during an enterprise client's security review, two weeks before launch.

This tutorial is the pass we run on client apps before release. It is deliberately practical: what to do, what it actually buys you, and where the usual advice is wrong.

Threat model first (five minutes, saves five days)

A mobile app is code you hand to an attacker. Anything shipped in the binary is readable, and anything the app can compute, an attacker can compute. So the honest goals are:

  1. Nothing valuable in the bundle. No long-lived API secrets, no private keys.
  2. Credentials at rest are protected by the platform keystore, not by a file in your sandbox.
  3. Traffic cannot be trivially intercepted or replayed by someone with a proxy and a custom CA.
  4. The server can tell a genuine app instance from a script, at least probabilistically.
  5. Reverse engineering is expensive, not impossible.

Everything below maps to one of those. If a mitigation maps to none of them, skip it.

1. Get secrets out of the binary

--dart-define does not hide anything. Neither does a .env file bundled as an asset, nor a base64 string, nor splitting the key into three constants and concatenating them. All of it lands in the app bundle as extractable bytes.

# how long "hiding" a key survives
unzip -o app-release.apk -d out/ >/dev/null
strings out/lib/arm64-v8a/libapp.so | grep -i 'sk_live\|AIza\|secret'

Rules we apply on every engagement:

  • Third-party secrets belong on your server. Stripe secret keys, Twilio credentials, OpenAI keys, SendGrid — the app calls your backend, your backend calls the vendor. If a feature currently calls a vendor directly with a secret, that is a backend ticket, not a mobile one.
  • Client identifiers are not secrets. Firebase google-services.json, a publishable Stripe key, a Maps API key — these are meant to be public. Protect them with server-side restrictions (SHA-1/bundle-ID restrictions, Firebase App Check, Maps key restrictions), not by hiding them.
  • Per-build config still uses --dart-define-from-file, because it keeps environments tidy — just never confuse tidy with secret.
flutter build appbundle --release \
  --dart-define-from-file=config/prod.json \
  --obfuscate --split-debug-info=build/symbols/prod
// lib/config/env.dart
class Env {
  static const apiBaseUrl = String.fromEnvironment('API_BASE_URL',
      defaultValue: 'https://api.example.com');
  static const flavor = String.fromEnvironment('FLAVOR', defaultValue: 'dev');
  static const isProd = flavor == 'prod';
}

Add a CI guard so a key can never sneak back in:

# tool/scan_secrets.sh — run in CI after the release build
set -euo pipefail
PATTERNS='sk_live_|AIza[0-9A-Za-z_-]{20,}|-----BEGIN (RSA|EC|PRIVATE)'
if strings build/app/outputs/**/libapp.so | grep -Eq "$PATTERNS"; then
  echo '::error::possible secret found in release binary'; exit 1
fi

2. Store credentials where the OS protects them

SharedPreferences is a plaintext XML/plist file. On a rooted or jailbroken device — and on any device with a backup extraction — it is readable. Refresh tokens, session cookies, PII and PINs go into the platform keystore via flutter_secure_storage.

dependencies:
  flutter_secure_storage: ^9.2.2
// lib/data/token_store.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class TokenStore {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(
      accessibility: KeychainAccessibility.first_unlock_this_device,
    ),
  );

  static const _refresh = 'refresh_token';

  Future<void> saveRefreshToken(String value) =>
      _storage.write(key: _refresh, value: value);

  Future<String?> readRefreshToken() => _storage.read(key: _refresh);

  Future<void> clear() => _storage.deleteAll();
}

Three details that bite teams in production:

  • first_unlock_this_device stops the keychain item from migrating to a restored device via iCloud backup. Use first_unlock only if cross-device restore is a product requirement.
  • Keychain survives app uninstall on iOS. After a reinstall you can read a token belonging to a user who is no longer signed in. Fix it by writing a flag to SharedPreferences (which is cleared) on first run and calling deleteAll() when the flag is missing.
  • Android auto-backup can exfiltrate your data directory. Set android:allowBackup="false" (or ship a data_extraction_rules.xml that excludes your stores) in AndroidManifest.xml.
Future<void> clearSecureStorageOnFreshInstall(SharedPreferences prefs) async {
  if (prefs.getBool('installed') != true) {
    await TokenStore().clear();
    await prefs.setBool('installed', true);
  }
}

Access tokens should stay in memory only, with a short TTL, and be re-minted from the refresh token. Never log them — including in Dio interceptors.

3. Keep tokens out of your logs and crash reports

class RedactingInterceptor extends Interceptor {
  static final _sensitive = {'authorization', 'cookie', 'set-cookie', 'x-api-key'};

  @override
  void onRequest(RequestOptions o, RequestInterceptorHandler h) {
    if (kDebugMode) {
      final headers = {
        for (final e in o.headers.entries)
          e.key: _sensitive.contains(e.key.toLowerCase()) ? '***' : e.value,
      };
      debugPrint('${o.method} ${o.uri} $headers');
    }
    h.next(o);
  }
}

And strip print from release entirely — a debugPrint override in main() plus a avoid_print lint rule is enough. Crashlytics/Sentry breadcrumbs need the same treatment: configure beforeSend to drop request bodies and query strings on auth endpoints.

4. Certificate pinning that does not brick your app

Pinning stops a casual proxy (mitmproxy with a user-installed CA) from reading your traffic. It also, done badly, takes your entire user base offline the day your certificate rotates. The safe version pins public keys (SPKI hashes), not certificates, and always ships a backup pin.

dependencies:
  dio: ^5.7.0
  http_certificate_pinning: ^2.1.3
import 'package:dio/dio.dart';
import 'package:http_certificate_pinning/http_certificate_pinning.dart';

Dio buildPinnedDio() {
  final dio = Dio(BaseOptions(baseUrl: Env.apiBaseUrl));
  if (Env.isProd) {
    dio.interceptors.add(
      CertificatePinningInterceptor(
        allowedSHAFingerprints: [
          'A1B2...CURRENT_LEAF_OR_INTERMEDIATE_SPKI',
          'C3D4...BACKUP_KEY_SPKI', // pre-issued rotation key
        ],
      ),
    );
  }
  return dio;
}

Get the SPKI hash with OpenSSL, and pin the intermediate if your provider rotates leaves frequently (most ACME setups do):

openssl s_client -servername api.example.com -connect api.example.com:443 \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary | base64

Operational rules we insist on:

  • Two pins minimum: current + a backup key held offline.
  • A server-controlled kill switch: a remote config flag that disables pinning, checked on a non-pinned endpoint, so a mis-rotation is a config change rather than an app-store release.
  • Pinning off in debug/staging, or your own QA cannot use a proxy.
  • A calendar reminder 60 days before certificate expiry, owned by a human, not a wiki page.

If your organization cannot commit to that operational discipline, skip pinning and rely on TLS plus attestation. A bricked app is a worse outcome than a readable API call.

5. Prove the caller is your app: Play Integrity and App Attest

Pinning protects the channel; attestation protects the endpoint from scripted abuse — credential stuffing, coupon farming, fake signups. Both platforms give you a signed statement your backend can verify.

// lib/security/attestation.dart
import 'package:flutter/services.dart';

class Attestation {
  static const _ch = MethodChannel('app/attestation');

  /// Returns a platform token to attach to a sensitive request.
  /// Android: Play Integrity token. iOS: App Attest assertion.
  static Future<String?> token(String nonce) async {
    try {
      return await _ch.invokeMethod<String>('attest', {'nonce': nonce});
    } on PlatformException {
      return null; // degrade, do not crash
    }
  }
}

class AttestationInterceptor extends Interceptor {
  @override
  Future<void> onRequest(RequestOptions o, RequestInterceptorHandler h) async {
    if (o.extra['sensitive'] == true) {
      final nonce = o.headers['x-nonce'] as String? ?? '';
      final t = await Attestation.token(nonce);
      if (t != null) o.headers['x-attestation'] = t;
    }
    h.next(o);
  }
}

The non-negotiable parts:

  • The server issues the nonce and verifies the token server-side (Play Integrity API / Apple's App Attest verification). A client that checks its own attestation result is theatre — the check is one patched branch away from true.
  • Attest sparingly. Login, payment, and account creation, not every list fetch. Tokens are rate-limited and slow.
  • Degrade, don't block. Some legitimate devices (no Play Services, enterprise images, older hardware) will fail. Treat a missing token as a risk signal that raises friction — an extra OTP — rather than a hard denial.
  • If you already use Firebase, App Check wraps both providers and enforces them at the Firebase backend with far less code.

6. Root/jailbreak detection: a signal, not a gate

dependencies:
  flutter_jailbreak_detection: ^1.10.0
final compromised = await FlutterJailbreakDetection.jailbroken;
if (compromised) {
  analytics.log('device_integrity_fail');   // send as a signal
  if (feature.blockOnRooted) showDegradedModeBanner();
}

Every client-side detector can be hooked by Frida in about ten minutes. Report the signal to your backend, use it in risk scoring, and only hard-block if a regulator makes you (some banking and payment schemes do). Blocking a rooted power user with no fraud history mostly generates support tickets.

7. Obfuscation and debug hygiene

Dart AOT already compiles to machine code, but symbol names survive unless you obfuscate:

flutter build appbundle --release \
  --obfuscate --split-debug-info=build/symbols/$VERSION

Archive build/symbols/ per release — without it, Crashlytics stack traces are unreadable. Upload symbols in CI:

firebase crashlytics:symbols:upload --app=$ANDROID_APP_ID build/symbols/$VERSION

Also, before you ship:

  • android:debuggable false, flutter_test/mock flavors excluded, kReleaseMode guards around any dev menu.
  • Android network security config with cleartextTrafficPermitted="false" and no user CAs trusted in release.
  • iOS: NSAllowsArbitraryLoads removed from Info.plist.
  • Screenshot protection on sensitive screens (FLAG_SECURE on Android; an overlay or secure_application on iOS) if you handle financial or health data.
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
  <base-config cleartextTrafficPermitted="false">
    <trust-anchors>
      <certificates src="system" />
    </trust-anchors>
  </base-config>
</network-security-config>

8. Watch your dependency surface

A Flutter app is 40–120 transitive packages, several of which run native code. Make that visible:

dart pub outdated                     # stale and discontinued packages
flutter pub deps --style=compact      # what is actually pulled in

In CI, fail the build on discontinued packages and on any new plugin added without review. For enterprise clients we also produce an SBOM per release and record each plugin's permission footprint — that document usually answers half of a client security questionnaire on its own.

The pre-release checklist

[ ] No vendor secrets in the bundle (strings scan runs in CI)
[ ] Tokens in flutter_secure_storage; access token memory-only
[ ] Keychain cleared on fresh install (iOS reinstall case)
[ ] allowBackup=false / extraction rules exclude app data
[ ] Headers and bodies redacted in logs and crash reports
[ ] TLS enforced; cleartext disabled; user CAs untrusted in release
[ ] Pinning with backup pin + remote kill switch (or documented decision to skip)
[ ] Attestation on login/payment, verified server-side, degrades gracefully
[ ] Release build obfuscated; symbols archived and uploaded
[ ] Dependency review + SBOM attached to the release

Where this usually lands

Most of this is a two-to-three day pass on an existing codebase, plus backend work to move vendor secrets server-side and verify attestation tokens. The expensive part is never the Flutter code — it is discovering in week eleven that a payment integration was written client-side and has to be rebuilt.

If you want a second pair of eyes on a Flutter codebase before an enterprise security review, get in touch — our consultants do exactly this pass, and hand back the checklist above filled in with evidence.