HomeDocumentationFlutter: The Enterprise Playbook
Flutter: The Enterprise Playbook
10

Securing Flutter Apps: Beyond the Basics

Securing Flutter Apps: Storage, Tokens, Pinning & More

March 23, 2026

Two of the most common Flutter security mistakes look like solutions.

The first: storing an authentication token in SharedPreferences because it's simple, it persists across sessions, and it works. The second: using --dart-define=API_KEY=your_secret_key to keep secrets out of source code, because someone on Stack Overflow said it was the safe way to handle API keys in Flutter.

Both are wrong. SharedPreferences stores data as plaintext on the device — on a rooted Android phone, an attacker reads it in seconds. --dart-define embeds the value in the compiled binary — anyone with the APK and the strings command can extract it in under a minute.

These aren't obscure edge cases. They're the default path for developers who haven't specifically studied mobile security, and they're present in a large percentage of production Flutter apps.

This article covers the security layer that most Flutter applications need and few fully implement. Not because making a list of security rules is useful on its own — but because understanding why each measure exists makes you able to reason about the ones not on the list.

The Right Mindset First

Mobile security is not about making your app impossible to attack. Nothing is. An attacker with physical access to a device, unlimited time, and sufficient expertise can extract anything on it. The goal is to make the cost of attack exceed the value of what can be gained.

This means security decisions should be proportionate to risk. A personal productivity app and a banking app face different threat models. Implementing certificate pinning, root detection, and hardware-backed encryption for an app that stores grocery lists is over-engineering. On the other hand, not implementing secure token storage for an app that accesses payroll data is negligence.

Assess what you're protecting, who might want it, and what they'd gain. Then implement proportionate measures. That's the frame for everything that follows.

Secure Storage: The Foundation

The most basic security requirement in most apps: storing sensitive data so that it can't be read by other apps, other users, or a casual attacker with device access.

SharedPreferences fails this completely. On Android it writes an XML file to the app's data directory. On iOS it writes a plist. These files are plaintext. On a rooted Android device or a jailbroken iPhone, any app can read them. On a non-rooted device, they're safe from other apps — but not from someone with physical access who can root the device.

flutter_secure_storage is the correct tool:

dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureTokenStorage {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(
      encryptedSharedPreferences: true, // AES-256 via Android Keystore
    ),
    iOptions: IOSOptions(
      accessibility: KeychainAccessibility.first_unlock_this_device,
    ),
  );

  static Future<void> saveToken(String token) async {
    await _storage.write(key: 'access_token', value: token);
  }

  static Future<String?> getToken() async {
    return _storage.read(key: 'access_token');
  }

  static Future<void> saveRefreshToken(String token) async {
    await _storage.write(key: 'refresh_token', value: token);
  }

  static Future<String?> getRefreshToken() async {
    return _storage.read(key: 'refresh_token');
  }

  // Call this on logout — clear everything
  static Future<void> clearAll() async {
    await _storage.deleteAll();
  }
}

On iOS, this uses Keychain — hardware-backed, encrypted, tied to the app's bundle ID. On Android, it uses EncryptedSharedPreferences backed by the Android Keystore — hardware-backed on devices with a secure enclave, software-backed on older hardware.

The KeychainAccessibility.first_unlock_this_device option means data is accessible after the device is first unlocked after a reboot, but not before. IOSOptions has several accessibility levels — pick one appropriate to your use case. always_this_device_only is the least restrictive, when_passcode_set_this_device_only the most.

What belongs in secure storage: authentication tokens, refresh tokens, session identifiers, user-specific encryption keys.

What doesn't: large amounts of data (Keychain has size limits and performance costs), data that doesn't need to be sensitive, secrets that shouldn't be on the device at all.

API Keys: What "Safe" Actually Means

Let's be specific about what --dart-define does and doesn't do:

bash
# What this does NOT do: hide the value from the compiled binary
flutter build apk --dart-define=MAPS_API_KEY=AIzaSyAbCdEfGhIjKlMnOpQrStUv

--dart-define keeps the value out of your source code. It does not keep it out of the compiled binary. The APK contains the value. Anyone who downloads your app from the Play Store can unzip the APK and find it. This is not hypothetical — automated bots scan published APKs for API keys routinely.

The correct model depends on what kind of key it is:

Publishable / public keys — safe in the app binary. Stripe's publishable key, Google Maps API key with domain restrictions, analytics write keys. These are designed to be public, often have usage restrictions configured at the provider level, and can't be used to impersonate your backend.

Secret / private keys — never in the app binary. Stripe's secret key, OpenAI API key, database credentials, webhook secrets. These must live on your server. If your app needs to use one, it calls your backend, which uses the key server-side and returns only the result.

dart
// ❌ Secret key in the app — extractable from the binary
class PaymentService {
  static const _stripeSecretKey = 'sk_live_...'; // Never do this

  Future<void> createCharge(double amount) async {
    await dio.post(
      'https://api.stripe.com/v1/charges',
      options: Options(headers: {'Authorization': 'Bearer $_stripeSecretKey'}),
    );
  }
}

// ✅ Backend proxy — secret key never leaves your server
class PaymentService {
  Future<void> createCharge(double amount) async {
    // Your backend calls Stripe — the secret key stays server-side
    await dio.post('/api/payments/charge', data: {'amount': amount});
  }
}

For keys that are technically publishable but should be restricted — Google Maps, for instance — configure key restrictions at the provider. A Maps key restricted to your app's package name and SHA-1 fingerprint can't be used by other apps even if someone extracts it.

Token Storage and the Access / Refresh Pattern

Authentication tokens are the most common target for session hijacking. The correct pattern — access token with short expiry, refresh token with longer expiry — limits the damage window even if an access token is compromised.

dart
// In Clean Architecture, this lives in the data layer
class AuthTokenRepository {
  Future<AuthTokens?> getTokens() async {
    final accessToken = await SecureTokenStorage.getToken();
    final refreshToken = await SecureTokenStorage.getRefreshToken();

    if (accessToken == null || refreshToken == null) return null;
    return AuthTokens(access: accessToken, refresh: refreshToken);
  }

  Future<void> saveTokens(AuthTokens tokens) async {
    await SecureTokenStorage.saveToken(tokens.access);
    await SecureTokenStorage.saveRefreshToken(tokens.refresh);
  }

  Future<void> clearTokens() async {
    await SecureTokenStorage.clearAll();
  }
}

// In the HTTP client — automatic token refresh
class SecureHttpClient {
  final Dio _dio;
  final AuthTokenRepository _tokenRepo;

  SecureHttpClient(this._dio, this._tokenRepo) {
    _dio.interceptors.add(
      InterceptorsWrapper(
        onRequest: (options, handler) async {
          final tokens = await _tokenRepo.getTokens();
          if (tokens != null) {
            options.headers['Authorization'] = 'Bearer ${tokens.access}';
          }
          handler.next(options);
        },
        onError: (error, handler) async {
          if (error.response?.statusCode == 401) {
            // Try to refresh
            final refreshed = await _refreshToken();
            if (refreshed) {
              // Retry the original request with new token
              final tokens = await _tokenRepo.getTokens();
              error.requestOptions.headers['Authorization'] =
                  'Bearer ${tokens!.access}';
              final response = await _dio.fetch(error.requestOptions);
              return handler.resolve(response);
            }
          }
          handler.next(error);
        },
      ),
    );
  }
}

On logout, clearTokens() removes everything from secure storage. Tokens that exist only in Keychain / EncryptedSharedPreferences and are cleared on logout are meaningfully harder to steal than tokens in SharedPreferences.

Biometric Authentication

Biometric authentication in Flutter — via the local_auth package — adds a user verification gate without requiring password re-entry. It's not authentication against your server. It's device-local verification that unlocks access to stored credentials.

The pattern: sensitive operation triggers biometric prompt, success unlocks access to the secure storage token, which authenticates the server request.

dart
import 'package:local_auth/local_auth.dart';

class BiometricGate {
  static final _auth = LocalAuthentication();

  static Future<bool> isAvailable() async {
    final canCheck = await _auth.canCheckBiometrics;
    final isDeviceSupported = await _auth.isDeviceSupported();
    return canCheck && isDeviceSupported;
  }

  static Future<bool> authenticate({
    required String reason,
    bool allowPinFallback = true,
  }) async {
    try {
      return await _auth.authenticate(
        localizedReason: reason,
        options: AuthenticationOptions(
          biometricOnly: !allowPinFallback,
          stickyAuth: true, // don't cancel when app goes to background
        ),
      );
    } on PlatformException {
      return false;
    }
  }
}

// Usage — gating a sensitive action
Future<void> viewPaymentDetails() async {
  final authenticated = await BiometricGate.authenticate(
    reason: 'Verify your identity to view payment details',
  );

  if (!authenticated) return;

  // Proceed with sensitive operation
}

stickyAuth: true prevents the authentication dialog from dismissing if the user switches apps and returns. Without it, backgrounding the app during biometric prompts cancels authentication silently — a UX problem that's also a security gap.

Certificate Pinning

HTTPS protects against eavesdropping on the network. Certificate pinning protects against a compromised or malicious certificate authority issuing a fraudulent certificate for your domain — a real attack vector used in corporate MITM proxies, compromised CAs, and targeted nation-state attacks.

Pinning tells your app to accept only a specific certificate (or public key) for your domain, regardless of what the device's trusted CA store says.

dart
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/services.dart';

Future<Dio> createPinnedClient() async {
  final dio = Dio();

  // Load your certificate from assets
  final certBytes = await rootBundle.load('assets/certs/api_cert.pem');

  (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
    final context = SecurityContext()
      ..setTrustedCertificatesBytes(certBytes.buffer.asUint8List());

    final client = HttpClient(context: context);
    // Reject certificates not matching the pinned cert
    client.badCertificateCallback = (cert, host, port) => false;
    return client;
  };

  return dio;
}

Store your certificate in assets/certs/ and declare it in pubspec.yaml. The certificate is the one from your API server — export it from your server's TLS configuration.

The maintenance cost. When your certificate expires and you rotate it, you must ship an app update with the new certificate before the old one expires. Failing to plan this rotation has caused production outages. Public key pinning — pinning the server's public key rather than the full certificate — is longer-lived since public keys often survive certificate renewals, but requires more careful management.

When to use it. Fintech, healthcare, any application handling data that's valuable enough to justify both the implementation and the maintenance commitment. Consumer apps with normal data risk don't typically need this.

For Android, certificate pinning can also be configured in the platform-level Network Security Config without touching Dart code:

xml
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
  <domain-config cleartextTrafficPermitted="false">
    <domain includeSubdomains="true">api.yourapp.com</domain>
    <pin-set expiration="2027-01-01">
      <!-- Primary certificate public key hash (SHA-256, base64) -->
      <pin digest="SHA-256">your_primary_cert_hash_base64==</pin>
      <!-- Backup pin — for rotation -->
      <pin digest="SHA-256">your_backup_cert_hash_base64==</pin>
    </pin-set>
  </domain-config>
</network-security-config>

Reference this file in AndroidManifest.xml:

xml
<application
  android:networkSecurityConfig="@xml/network_security_config"
  ...>

Obfuscation

Obfuscation makes your compiled Dart code harder to reverse-engineer. It renames classes, methods, and fields to meaningless identifiers. It doesn't prevent determined attackers — decompilation tools can still read the logic — but it raises the effort cost significantly.

bash
# Build with obfuscation — requires --split-debug-info
flutter build apk \
  --obfuscate \
  --split-debug-info=build/debug-info/android/

flutter build ipa \
  --obfuscate \
  --split-debug-info=build/debug-info/ios/

--split-debug-info is mandatory with --obfuscate. It saves the symbol mapping to a separate directory so you can still symbolicate crash reports from Crashlytics, Sentry, or similar tools. Without it, obfuscated crash stacks are unreadable. Store the debug info files securely — they map obfuscated names back to your actual code.

On Android, R8 (the default code shrinker since Android Gradle Plugin 3.4) provides additional bytecode obfuscation at the native layer. It's enabled by default in release builds.

Screenshot and Screen Recording Protection

For apps handling sensitive data, preventing the OS from capturing the screen is worth implementing. Screenshots and screen recordings can expose financial data, health records, authentication tokens displayed in UI during debugging.

Android — set FLAG_SECURE on the window:

kotlin
// android/app/src/main/kotlin/com/yourapp/MainActivity.kt
import android.view.WindowManager

class MainActivity : FlutterActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // Prevents screenshots and screen recording
    window.setFlags(
      WindowManager.LayoutParams.FLAG_SECURE,
      WindowManager.LayoutParams.FLAG_SECURE
    )
  }
}

iOS — add a secure overlay when the app goes to background (prevents the OS thumbnail capture):

swift
// ios/Runner/AppDelegate.swift
func applicationWillResignActive(_ application: UIApplication) {
  let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
  blurView.frame = window!.frame
  blurView.tag = 999
  window?.addSubview(blurView)
}

func applicationDidBecomeActive(_ application: UIApplication) {
  window?.viewWithTag(999)?.removeFromSuperview()
}

This prevents the screenshot the OS takes when the app moves to background — the one visible in the app switcher.

Root and Jailbreak Detection

Rooted and jailbroken devices bypass most of the security measures above. Secure storage can be read. Biometric checks can be bypassed. Certificate pinning can be stripped.

The flutter_jailbreak_detection package provides basic detection:

dart
import 'package:flutter_jailbreak_detection/flutter_jailbreak_detection.dart';

Future<bool> isDeviceCompromised() async {
  final isJailbroken = await FlutterJailbreakDetection.jailbroken;
  final isDeveloperMode = await FlutterJailbreakDetection.developerMode;
  return isJailbroken || isDeveloperMode;
}

// On app launch
if (await isDeviceCompromised()) {
  // Show warning and optionally restrict functionality
  showDialog(
    context: context,
    builder: (_) => const AlertDialog(
      title: Text('Security Warning'),
      content: Text('This device appears to be rooted or jailbroken. '
          'For your security, some features may be restricted.'),
    ),
  );
}

The honest caveat: jailbreak detection can be bypassed. Tools like Liberty Lite on iOS and Magisk on Android specifically hide root from detection. A sophisticated attacker who has rooted a device specifically to attack your app will bypass basic detection.

Treat it as a speed bump, not a wall. It stops casual attempts and automated attacks. It doesn't stop a targeted, motivated attacker. For high-security applications, combine detection with server-side signals (device attestation via Apple's App Attest and Google's Play Integrity API) rather than relying on client-side detection alone.

Platform-Level Security Configuration

Both platforms have security settings that apply at the network layer, before your Dart code is involved.

iOS — App Transport Security. By default, iOS requires HTTPS for all network connections. This is the right setting. Resist the temptation to add NSAllowsArbitraryLoads to your Info.plist to fix a connectivity issue — it disables HTTPS enforcement entirely. Find the actual root cause instead.

xml
<!-- ios/Runner/Info.plist — the correct setting: nothing -->
<!-- ATS is strict by default. Don't override it. -->

<!-- ❌ What many tutorials suggest to "fix" connection issues -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/> <!-- This disables HTTPS enforcement for the entire app -->
</dict>

Android — Network Security Config. The network_security_config.xml file introduced above does more than certificate pinning. It also controls cleartext (HTTP) traffic:

xml
<network-security-config>
  <!-- Block all cleartext traffic -->
  <base-config cleartextTrafficPermitted="false">
    <trust-anchors>
      <certificates src="system" />
    </trust-anchors>
  </base-config>
</network-security-config>

This is the Android equivalent of ATS. Applied globally, it means any HTTP request from the app fails at the network layer.

Where Security Lives in the Architecture

Following the Clean Architecture pattern, security concerns distribute across the layers with clear responsibilities:

Data layer — where most security implementation lives. Token storage, certificate pinning configuration, HTTP client with auth headers and refresh logic. SecureTokenStorage, AuthTokenRepository, SecureHttpClient — all data layer classes.

Domain layer — defines the contracts. An AuthRepository interface that specifies token storage behaviour without knowing it uses Keychain. Use cases that gate sensitive operations without knowing the implementation of the gate.

Presentation layer — biometric prompts, security warnings, screen protection. The BiometricGate is called from use cases or directly from BLoC event handlers. Screenshot protection is configured at the platform level, invisible to Dart.

No security check belongs in a widget. A widget that decides whether to show data based on an auth state is fine — the auth state is managed by the domain and data layers, and the widget only reads it.

The Checklist, With Context

Every measure above is worth implementing for most production apps:

  1. flutter_secure_storage — non-negotiable for any app that stores tokens
  2. No secrets in the binary — non-negotiable, full stop
  3. Access / refresh token pattern — standard for any auth implementation
  4. Obfuscation — low cost, worth it for any release app
  5. Platform ATS / Network Security Config — set once, forget
  6. Biometric gate — implement for sensitive operations, not for the whole app
  7. Certificate pinning — for fintech, healthcare, high-value data
  8. Screenshot protection — for apps displaying sensitive data on-screen
  9. Root / jailbreak detection — for apps where the threat model warrants it, combined with server-side attestation for serious cases

Security is not a feature you add at the end. It's a set of decisions you make throughout the build that collectively raise the cost of attack above the value of the target. The measures above don't guarantee security — nothing does. They make your app significantly harder to attack than the large fraction of production apps that haven't thought about any of this.

Related Topics

flutter securityflutter secure storagecertificate pinningflutter biometric authflutter obfuscationapi key securityflutter token refresh

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.