Steven's Knowledge

Security

Flutter security — secure storage, SSL pinning, code obfuscation, authentication, data protection

Security

A Flutter app ships as a binary on the user's device. Unlike a web app behind a server, the binary can be decompiled, the local storage can be read, and every network call can be intercepted. Security is not a feature you bolt on — it is a constraint that shapes storage, networking, build, and authentication decisions from day one.

Threat Model for Mobile Apps

What's Different from Web

DimensionWebMobile
Code accessServer-side code is hidden; client JS is visible but ephemeralFull binary lives on the device; can be extracted and decompiled at leisure
StorageCookies (httpOnly, Secure), sessionStorageFiles on a filesystem the user (or malware) can read
NetworkHTTPS enforced by browsersApp controls its own HTTP stack; proxies like Charles/mitmproxy can intercept if pinning is absent
RuntimeBrowser sandboxOS sandbox, but root/jailbreak removes it entirely
DistributionServed fresh on every requestInstalled once; updates are asynchronous and optional

Threat Matrix

ThreatAttack VectorImpactPrimary Mitigation
Network interceptionProxy tools (mitmproxy, Charles) on compromised Wi-FiToken theft, data exfiltrationCertificate pinning, TLS 1.3
Reverse engineeringExtracting APK/IPA, decompiling Dart snapshotLeaked API keys, business logic exposureObfuscation, server-side secrets
Local data theftDevice backup extraction, rooted filesystem readPII leakage, token reuseEncrypted storage, data protection levels
Session hijackingStolen refresh token from insecure storageFull account takeoverKeychain/Keystore storage, token rotation
Insecure storageSharedPreferences in plaintext XMLCredential theftflutter_secure_storage, encrypted databases
TamperingPatching the binary, Frida hookingBypassed paywalls, forged requestsIntegrity checks, root detection

No client-side security is absolute. A determined attacker with physical access and a rooted device can bypass every measure listed here. The goal is defense in depth: raise the cost of attack high enough that it exceeds the value of the target, and ensure your server never trusts the client unconditionally.

Secure Storage

flutter_secure_storage

The standard approach for storing sensitive values. On iOS it uses the Keychain; on Android it uses EncryptedSharedPreferences (AES-256 via the AndroidX Security library).

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

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

  static const _keyAccessToken = 'access_token';
  static const _keyRefreshToken = 'refresh_token';

  Future<void> saveTokens({
    required String accessToken,
    required String refreshToken,
  }) async {
    await Future.wait([
      _storage.write(key: _keyAccessToken, value: accessToken),
      _storage.write(key: _keyRefreshToken, value: refreshToken),
    ]);
  }

  Future<String?> get accessToken => _storage.read(key: _keyAccessToken);
  Future<String?> get refreshToken => _storage.read(key: _keyRefreshToken);

  Future<void> clearAll() async {
    await _storage.deleteAll();
  }
}

When to Use Secure vs Regular Storage

Data TypeStorageWhy
Auth tokens (access, refresh)flutter_secure_storageStolen tokens = full account takeover
PII (email, phone, SSN)flutter_secure_storage or encrypted DBRegulatory and liability exposure
Encryption keysflutter_secure_storageKey leakage defeats encryption entirely
User preferences (theme, locale)SharedPreferencesNo security value; no encryption overhead
Cached API responses (non-sensitive)Hive / drift / file cachePerformance benefit; no secrets involved

Biometric-Gated Access

Combine local_auth with secure storage to require fingerprint or face recognition before reading sensitive data:

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

class BiometricVault {
  final _auth = LocalAuthentication();
  final _storage = const FlutterSecureStorage();

  Future<String?> readProtectedValue(String key) async {
    final canAuthenticate = await _auth.canCheckBiometrics ||
        await _auth.isDeviceSupported();

    if (!canAuthenticate) return null;

    final authenticated = await _auth.authenticate(
      localizedReason: 'Authenticate to access secure data',
      options: const AuthenticationOptions(
        stickyAuth: true,
        biometricOnly: false, // allow PIN/pattern fallback
      ),
    );

    if (!authenticated) return null;
    return _storage.read(key: key);
  }
}

Encrypted Database

For structured sensitive data, use drift with sqlcipher or isar with encryption:

// drift + sqlcipher example
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';
import 'package:sqlite3/open.dart';

LazyDatabase openEncryptedDb(String path, String passphrase) {
  return LazyDatabase(() async {
    // Ensure sqlcipher is loaded
    open.overrideFor(OperatingSystem.android, openCipherOnAndroid);

    return NativeDatabase.createInBackground(
      File(path),
      setup: (db) {
        // Set the encryption key — must be first statement
        db.execute("PRAGMA key = '$passphrase';");
        db.execute('PRAGMA cipher_compatibility = 4;');
      },
    );
  });
}

Store the database passphrase in flutter_secure_storage, not hardcoded. The passphrase should be generated randomly on first launch and persisted in the Keychain/Keystore. Hardcoding it in the binary defeats the entire purpose of encryption.

Network Security

Certificate Pinning with Dio

Pin to the server's public key or certificate to prevent man-in-the-middle attacks even on compromised networks:

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

Dio createPinnedClient() {
  final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));

  dio.httpClientAdapter = IOHttpClientAdapter(
    createHttpClient: () {
      final client = HttpClient();
      client.badCertificateCallback = (cert, host, port) => false;

      // Pin the expected certificate fingerprint
      final context = SecurityContext();
      context.setTrustedCertificatesBytes(_pinnedCertBytes);
      return HttpClient(context: context);
    },
  );

  return dio;
}

// Alternative: pin by public key hash in an interceptor
class CertificatePinningInterceptor extends Interceptor {
  // SHA-256 of the SubjectPublicKeyInfo
  static const _expectedPin =
      'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    // Pinning is enforced at the HttpClient level above.
    // This interceptor can add additional header-based verification
    // or log pinning events for monitoring.
    handler.next(options);
  }
}

iOS App Transport Security (ATS)

ATS enforces HTTPS by default on iOS. Never disable it globally.

<!-- ios/Runner/Info.plist -->
<!-- WRONG: disables all transport security -->
<!--
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <true/>
</dict>
-->

<!-- RIGHT: exception only for a specific legacy domain -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSExceptionDomains</key>
  <dict>
    <key>legacy-api.example.com</key>
    <dict>
      <key>NSExceptionAllowsInsecureHTTPLoads</key>
      <true/>
      <key>NSExceptionMinimumTLSVersion</key>
      <string>TLSv1.2</string>
    </dict>
  </dict>
</dict>

Android Network Security Config

<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
  <!-- Deny cleartext traffic globally -->
  <base-config cleartextTrafficPermitted="false">
    <trust-anchors>
      <certificates src="system" />
    </trust-anchors>
  </base-config>

  <!-- Pin certificates for your API domain -->
  <domain-config>
    <domain includeSubdomains="true">api.example.com</domain>
    <pin-set expiration="2027-01-01">
      <pin digest="SHA-256">base64EncodedSPKIHash1=</pin>
      <pin digest="SHA-256">base64EncodedBackupPin=</pin>
    </pin-set>
  </domain-config>
</network-security-config>

Reference it in your AndroidManifest.xml:

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

API Key Protection

Never ship API keys, signing secrets, or database credentials in the binary. Dart strings survive obfuscation and can be extracted with strings or a hex editor. Use a server-side proxy: the mobile app authenticates to your backend, and your backend calls third-party APIs with the real key.

For keys that genuinely must exist on the device (e.g., a Firebase config, a Maps SDK key), restrict them server-side:

  • Firebase: unrestricted by design (protected by Security Rules, not key secrecy)
  • Google Maps: restrict by app package name and SHA-1 fingerprint in the Cloud Console
  • Stripe publishable key: designed to be public; never ship the secret key
// WRONG: hardcoded secret in Dart source
const apiSecret = 'sk_live_XXXXXXXXXXXXXXXX';

// RIGHT: compile-time injection for non-secret, restricted keys only
const mapsKey = String.fromEnvironment('MAPS_API_KEY');

// Build with:
// flutter build apk --dart-define=MAPS_API_KEY=$MAPS_API_KEY

Code Obfuscation and Reverse Engineering

Build Flags

Always obfuscate release builds:

flutter build apk \
  --obfuscate \
  --split-debug-info=build/debug-info

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

What Obfuscation Does and Does Not Protect

ProtectedNot Protected
Class names (renamed to a, b, c...)String literals ("https://api.example.com")
Method namesInteger/boolean constants
Field namesControl flow and algorithm logic
Library structureAsset files bundled in the APK/IPA

An attacker decompiling a Flutter app sees the Dart snapshot format. Tools like darter or reFlutter can parse the snapshot and reconstruct class hierarchies. Obfuscation makes this output harder to read but does not prevent it.

Symbol Map Management

The --split-debug-info directory contains symbol maps needed to decode stack traces from crash reporters:

# Upload to Sentry
sentry-cli upload-dif --org my-org --project my-app build/debug-info/

# Upload to Firebase Crashlytics
firebase crashlytics:symbols:upload \
  --app=1:123456:android:abc123 \
  build/debug-info/

Without these symbols, crash reports from obfuscated builds are unreadable.

Additional Protections

For high-security apps (banking, health), consider layering beyond basic obfuscation:

// Runtime string decryption — store encrypted, decrypt at use
import 'dart:convert';
import 'package:crypto/crypto.dart';

class ObfuscatedStrings {
  /// XOR-based string deobfuscation. The key and encrypted value
  /// are generated at build time by a custom build script.
  static String decode(List<int> encoded, List<int> key) {
    final result = <int>[];
    for (var i = 0; i < encoded.length; i++) {
      result.add(encoded[i] ^ key[i % key.length]);
    }
    return utf8.decode(result);
  }
}

Root and Jailbreak Detection

Why It Matters

On a rooted/jailbroken device, the OS trust model is compromised. An attacker can:

  • Read any app's sandbox files (bypassing secure storage)
  • Attach Frida or objection to hook methods at runtime
  • Disable certificate pinning by replacing the trust store
  • Modify the app binary in place

Implementation with freeRASP

import 'package:freerasp/freerasp.dart';

Future<void> initSecurityChecks() async {
  final config = TalsecConfig(
    androidConfig: AndroidConfig(
      packageName: 'com.example.app',
      signingCertHashes: ['EXPECTED_HASH'],
      supportedStores: ['com.android.vending'], // Google Play only
    ),
    iosConfig: IOSConfig(
      bundleIds: ['com.example.app'],
      teamId: 'TEAM_ID',
    ),
    watcherMail: 'security@example.com',
  );

  final callback = ThreatCallback(
    onRootDetected: () => _handleThreat('root'),
    onJailbreakDetected: () => _handleThreat('jailbreak'),
    onDebuggerDetected: () => _handleThreat('debugger'),
    onTamperDetected: () => _handleThreat('tamper'),
    onHookDetected: () => _handleThreat('hook'),
    onUnofficialStoreDetected: () => _handleThreat('unofficial_store'),
  );

  await Talsec.instance.start(config, callback: callback);
}

void _handleThreat(String type) {
  // Tiered response based on threat severity and app requirements
  switch (type) {
    case 'root':
    case 'jailbreak':
      // Option 1: warn and restrict sensitive features
      // Option 2: refuse to run entirely (banking apps)
      _restrictSensitiveFeatures();
    case 'debugger':
    case 'hook':
      // Likely active attack — terminate immediately
      _terminateApp();
    case 'tamper':
      // Binary has been modified — refuse to run
      _terminateApp();
    default:
      _logSecurityEvent(type);
  }
}

Detection is always bypassable. A sufficiently motivated attacker can patch out any root detection check. The value is raising the bar: automated scripts and casual attackers are stopped, and you get telemetry on how many users run on compromised devices. Never rely on client-side detection as your only security layer — your server must validate everything independently.

Runtime Protection

Screenshot and Screen Recording Prevention

Prevent sensitive screens from appearing in screenshots, recent-apps thumbnails, and screen recordings:

import 'package:flutter/services.dart';

class ScreenProtection {
  static const _channel = MethodChannel('com.example.app/security');

  /// Call when entering a sensitive screen (e.g., account details)
  static Future<void> enableProtection() async {
    await _channel.invokeMethod('enableScreenProtection');
  }

  /// Call when leaving the sensitive screen
  static Future<void> disableProtection() async {
    await _channel.invokeMethod('disableScreenProtection');
  }
}

Android native side (MainActivity.kt):

// android/app/src/main/kotlin/.../MainActivity.kt
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger,
            "com.example.app/security"
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                "enableScreenProtection" -> {
                    window.setFlags(
                        WindowManager.LayoutParams.FLAG_SECURE,
                        WindowManager.LayoutParams.FLAG_SECURE
                    )
                    result.success(null)
                }
                "disableScreenProtection" -> {
                    window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
                    result.success(null)
                }
                else -> result.notImplemented()
            }
        }
    }
}

Background App Snapshot Obfuscation

iOS shows a snapshot of your app in the app switcher. Overlay a blur or blank view when the app enters the background:

import 'package:flutter/material.dart';

class SecureApp extends StatefulWidget {
  final Widget child;
  const SecureApp({super.key, required this.child});

  @override
  State<SecureApp> createState() => _SecureAppState();
}

class _SecureAppState extends State<SecureApp> with WidgetsBindingObserver {
  bool _obscured = false;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    setState(() {
      _obscured = state == AppLifecycleState.inactive ||
          state == AppLifecycleState.hidden;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        widget.child,
        if (_obscured)
          const Positioned.fill(
            child: ColoredBox(color: Colors.white),
          ),
      ],
    );
  }
}

Debug Mode Detection

import 'package:flutter/foundation.dart';

void initApp() {
  if (kDebugMode) {
    // Never reaches production, but explicit is better than implicit
    print('Running in debug mode — security checks relaxed');
    return;
  }

  if (kProfileMode) {
    // Profile builds should still enforce security
  }

  // kReleaseMode — full security enforcement
  _initSecurityChecks();
}

// Assert-based check (stripped in release builds)
bool get isDebugMode {
  bool debug = false;
  assert(() {
    debug = true;
    return true;
  }());
  return debug;
}

Clipboard Data Clearing

Clear sensitive data from the clipboard after a timeout to prevent other apps from reading it:

import 'dart:async';
import 'package:flutter/services.dart';

class SecureClipboard {
  static Timer? _clearTimer;

  /// Copy a value and auto-clear after [timeout].
  static Future<void> copyWithExpiry(
    String value, {
    Duration timeout = const Duration(seconds: 30),
  }) async {
    await Clipboard.setData(ClipboardData(text: value));

    _clearTimer?.cancel();
    _clearTimer = Timer(timeout, () {
      Clipboard.setData(const ClipboardData(text: ''));
    });
  }
}

Authentication Security

Secure Token Storage Pattern

Keep the access token in memory for speed; persist only the refresh token in secure storage:

class AuthManager {
  final SecureTokenStore _store;
  String? _accessToken; // in-memory only — cleared on app kill

  AuthManager(this._store);

  Future<void> initialize() async {
    final refreshToken = await _store.refreshToken;
    if (refreshToken != null) {
      await _refreshSession(refreshToken);
    }
  }

  Future<void> login(String email, String password) async {
    final response = await _api.login(email: email, password: password);
    _accessToken = response.accessToken;
    await _store.saveTokens(
      accessToken: response.accessToken,
      refreshToken: response.refreshToken,
    );
  }

  Future<String> getValidToken() async {
    if (_accessToken != null) return _accessToken!;

    final refreshToken = await _store.refreshToken;
    if (refreshToken == null) throw UnauthenticatedException();

    return _refreshSession(refreshToken);
  }

  Future<String> _refreshSession(String refreshToken) async {
    try {
      final response = await _api.refresh(refreshToken: refreshToken);
      _accessToken = response.accessToken;
      // Rotate: old refresh token is now invalid server-side
      await _store.saveTokens(
        accessToken: response.accessToken,
        refreshToken: response.refreshToken,
      );
      return response.accessToken;
    } catch (_) {
      // Refresh failed — force re-login
      _accessToken = null;
      await _store.clearAll();
      rethrow;
    }
  }

  Future<void> logout() async {
    try {
      await _api.revoke(refreshToken: (await _store.refreshToken)!);
    } finally {
      _accessToken = null;
      await _store.clearAll();
    }
  }
}

Session Timeout

Force re-authentication after a period of inactivity:

class SessionTimeoutManager with WidgetsBindingObserver {
  static const _timeout = Duration(minutes: 5);
  DateTime _lastActivity = DateTime.now();
  Timer? _timer;

  void start() {
    WidgetsBinding.instance.addObserver(this);
    _resetTimer();
  }

  void recordActivity() {
    _lastActivity = DateTime.now();
    _resetTimer();
  }

  void _resetTimer() {
    _timer?.cancel();
    _timer = Timer(_timeout, _onTimeout);
  }

  void _onTimeout() {
    final elapsed = DateTime.now().difference(_lastActivity);
    if (elapsed >= _timeout) {
      // Clear in-memory token, navigate to lock screen
      AuthManager.instance.lockSession();
    }
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      final elapsed = DateTime.now().difference(_lastActivity);
      if (elapsed >= _timeout) {
        _onTimeout();
      }
    }
  }

  void dispose() {
    _timer?.cancel();
    WidgetsBinding.instance.removeObserver(this);
  }
}

OAuth 2.0 + PKCE for Mobile

Use flutter_appauth for standards-compliant OAuth flows. PKCE (Proof Key for Code Exchange) prevents authorization code interception:

import 'package:flutter_appauth/flutter_appauth.dart';

class OAuthService {
  final _appAuth = const FlutterAppAuth();

  Future<TokenResponse?> signIn() async {
    final result = await _appAuth.authorizeAndExchangeCode(
      AuthorizationTokenRequest(
        'client_id',
        'com.example.app://oauth/callback',
        issuer: 'https://auth.example.com',
        scopes: ['openid', 'profile', 'email', 'offline_access'],
        // PKCE is enabled by default — code_verifier generated automatically
        additionalParameters: {'prompt': 'consent'},
      ),
    );

    if (result != null) {
      // Store tokens securely
      await SecureTokenStore().saveTokens(
        accessToken: result.accessToken!,
        refreshToken: result.refreshToken!,
      );
    }

    return result;
  }
}

A malicious app can register the same custom URL scheme and intercept your OAuth callback. Use verified app links (Android) and universal links (iOS) instead:

<!-- android/app/src/main/AndroidManifest.xml -->
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <!-- HTTPS link — verified against /.well-known/assetlinks.json -->
  <data android:scheme="https"
        android:host="auth.example.com"
        android:pathPrefix="/oauth/callback" />
</intent-filter>

On iOS, configure the Associated Domains entitlement and host an apple-app-site-association file. Custom schemes (myapp://) are not verified and should be avoided for auth callbacks.

Data Protection

Sensitive Data in Logs

// WRONG: tokens and PII visible in logcat / Console.app
print('Login response: $responseBody');
log('User token: ${user.accessToken}');

// RIGHT: redact sensitive fields
log('Login succeeded for user_id=${user.id}');

// Use a logging wrapper that strips sensitive keys
class SecureLogger {
  static const _redactedKeys = {
    'password', 'token', 'access_token', 'refresh_token',
    'ssn', 'credit_card', 'authorization',
  };

  static String sanitize(Map<String, dynamic> data) {
    return data.map((key, value) {
      if (_redactedKeys.contains(key.toLowerCase())) {
        return MapEntry(key, '[REDACTED]');
      }
      return MapEntry(key, value);
    }).toString();
  }
}

ProGuard / R8 Rules for Android

Flutter's Dart runtime and plugins need keep rules to survive minification:

# android/app/proguard-rules.pro

# Keep Flutter engine
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }

# Keep flutter_secure_storage native code
-keep class com.it_nomads.fluttersecurestorage.** { *; }

# Keep serialization classes used by json_serializable
-keepclassmembers class * {
  @com.google.gson.annotations.SerializedName <fields>;
}

# Strip metadata that could help reverse engineering
-dontwarn javax.annotation.**
-renamesourcefileattribute SourceFile
-keepattributes SourceFile,LineNumberTable

Enable R8 in android/gradle.properties:

android.enableR8=true

iOS Data Protection

Set the data protection level in your entitlements to ensure files are encrypted when the device is locked:

<!-- ios/Runner/Runner.entitlements -->
<key>com.apple.developer.default-data-protection</key>
<string>NSFileProtectionComplete</string>
LevelFiles Accessible When
NSFileProtectionCompleteOnly while device is unlocked
NSFileProtectionCompleteUnlessOpenUnlocked, or if file handle was opened before lock
NSFileProtectionCompleteUntilFirstUserAuthenticationAfter first unlock (default)
NSFileProtectionNoneAlways (do not use for sensitive data)

For maximum security, use NSFileProtectionComplete and handle the case where your app needs to write while backgrounded (use CompleteUnlessOpen for those specific files only).

Memory Safety

Sensitive data (passwords, tokens, decrypted secrets) should not linger in memory longer than necessary:

class SensitiveBuffer {
  List<int>? _data;

  void load(List<int> sensitiveBytes) {
    _data = List<int>.from(sensitiveBytes);
  }

  List<int>? read() => _data;

  /// Overwrite memory before releasing the reference.
  void clear() {
    if (_data != null) {
      for (var i = 0; i < _data!.length; i++) {
        _data![i] = 0;
      }
      _data = null;
    }
  }
}

Dart is garbage-collected, so you cannot guarantee when memory is reclaimed or that the GC will not copy objects during compaction. Zeroing the buffer is a best-effort measure. For truly sensitive cryptographic operations, use platform channels to call native code (Swift/Kotlin) that can manage memory more precisely, or use the pointycastle library operating on fixed-length Uint8List buffers.

GDPR and Privacy Considerations

  • Data minimization: only collect and store what the feature requires. Do not cache full API responses containing PII "just in case."
  • Right to erasure: implement a deleteAllUserData() method that clears secure storage, databases, file caches, and any analytics identifiers. Test it.
  • Consent: track consent state per purpose (analytics, personalization, marketing) and respect it at the collection point, not just in a settings screen.
  • Data retention: set TTLs on cached data. A token cache or PII cache with no expiry is a liability.
class PrivacyManager {
  final SecureTokenStore _tokenStore;
  final AppDatabase _db;

  PrivacyManager(this._tokenStore, this._db);

  /// Full data erasure — call on account deletion or GDPR request
  Future<void> eraseAllUserData() async {
    await Future.wait([
      _tokenStore.clearAll(),
      _db.deleteEverything(),
      _clearFileCache(),
      _resetAnalyticsId(),
    ]);
  }

  Future<void> _clearFileCache() async {
    final cacheDir = await getTemporaryDirectory();
    if (cacheDir.existsSync()) {
      await cacheDir.delete(recursive: true);
    }
  }

  Future<void> _resetAnalyticsId() async {
    // Reset any persistent analytics identifiers
    // so the user cannot be re-linked after erasure
  }
}

Security Checklist

Run through this before every release. Each item maps to a section above.

CategoryCheckPriority
StorageAuth tokens stored in flutter_secure_storage, not SharedPreferencesCritical
StorageDatabase passphrase stored in Keychain/Keystore, not hardcodedCritical
StorageNo PII in plaintext files or unencrypted databasesCritical
NetworkCertificate pinning enabled for all API endpointsHigh
NetworkATS enabled on iOS; cleartext traffic disabled on AndroidHigh
NetworkNo API secrets shipped in the binaryCritical
Binary--obfuscate and --split-debug-info used for release buildsHigh
BinaryDebug symbols uploaded to crash reporting serviceHigh
BinaryProGuard/R8 rules configured and testedMedium
AuthRefresh tokens rotated on each use; old tokens revoked server-sideHigh
AuthSession timeout implemented for sensitive appsMedium
AuthOAuth callbacks use verified/universal links, not custom schemesHigh
RuntimeRoot/jailbreak detection active with appropriate responseMedium
RuntimeFLAG_SECURE enabled on sensitive screensMedium
RuntimeBackground snapshot obscured on iOSMedium
RuntimeNo sensitive data in logs (avoid_print lint enabled)High
PrivacyFull data erasure method implemented and testedHigh
PrivacyData retention TTLs set on cached PIIMedium

This checklist is a floor, not a ceiling. For regulated industries (finance, health), engage a professional penetration testing firm and follow platform-specific compliance frameworks (PCI DSS, HIPAA, SOC 2). Client-side hardening is necessary but never sufficient — your server-side validation, rate limiting, and monitoring are where security is ultimately enforced.

On this page