strings, APKTool, or MobSF within seconds.# ✗ NEVER: hardcode in source # const apiKey = 'AIzaSyABC123...'; # ✓ Inject at build time via --dart-define $ flutter build apk \ --dart-define=API_KEY=your_api_key_here \ --dart-define=FIREBASE_PROJECT_ID=my-project \ --dart-define=BASE_URL=https://api.your-app.com \ --release # Or from a file (Flutter 3.7+) $ flutter build apk \ --dart-define-from-file=.env.production \ --release # .env.production format (JSON): # { "API_KEY": "xxx", "BASE_URL": "https://api.your-app.com" }
// Access dart-define values — only available after build injection abstract class Env { // fromEnvironment reads the --dart-define value static const String apiKey = String.fromEnvironment( 'API_KEY', defaultValue: '', ); static const String baseUrl = String.fromEnvironment( 'BASE_URL', defaultValue: 'https://api.your-app.com', ); static const bool isProduction = bool.fromEnvironment( 'IS_PROD', defaultValue: false, ); // Validate all required vars are set at app start static void validate() { assert(apiKey.isNotEmpty, 'API_KEY not set. Use --dart-define=API_KEY=...'); assert(baseUrl.isNotEmpty, 'BASE_URL not set.'); } }
void main() { // Validate secrets are injected before app launches Env.validate(); runApp(const MyApp()); }
dependencies: envied: ^0.5.4 dev_dependencies: envied_generator: ^0.5.4 build_runner: ^2.4.9
import 'package:envied/envied.dart'; part 'env.g.dart'; @Envied(path: '.env', obfuscate: true) // obfuscate=true XOR-encodes values abstract class Env { @EnviedField(varName: 'API_KEY', obfuscate: true) static const String apiKey = _Env.apiKey; @EnviedField(varName: 'BASE_URL', obfuscate: true) static const String baseUrl = _Env.baseUrl; }
⚠️ Add lib/env/env.g.dart and .env to .gitignore — never commit them.
SharedPreferences stores data as plaintext XML/JSON files in the app's data directory. On rooted Android devices or jailbroken iPhones, these files are fully readable. Never store tokens, passwords, or PII here.dependencies:
flutter_secure_storage: ^9.2.2 # Uses Android Keystore / iOS Keychain
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class SecureStorageService { static const _storage = FlutterSecureStorage( aOptions: AndroidOptions( encryptedSharedPreferences: true, // AES-256 via Keystore keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_OAEPwithSHA_256andMGF1Padding, storageCipherAlgorithm: StorageCipherAlgorithm.AES_GCM_NoPadding, ), iOptions: IOSOptions( accessibility: KeychainAccessibility.first_unlock_this_device, // Requires device authentication before access ), ); // ─── Write secure value ─────────────────────────────────────── static Future<void> write(String key, String value) async { await _storage.write(key: key, value: value); } // ─── Read secure value ──────────────────────────────────────── static Future<String?> read(String key) async { return await _storage.read(key: key); } // ─── Delete a key ───────────────────────────────────────────── static Future<void> delete(String key) async { await _storage.delete(key: key); } // ─── Wipe all data on logout ────────────────────────────────── static Future<void> deleteAll() async { await _storage.deleteAll(); } } // Storage key constants — centralise all key names abstract class StorageKeys { static const accessToken = 'access_token'; static const refreshToken = 'refresh_token'; static const deviceId = 'device_id'; // ✗ NEVER store raw passwords — only tokens }
📊 Storage decision matrix:
| Data Type | Storage | Safe? |
|---|---|---|
| Auth tokens, passwords | flutter_secure_storage | ✅ Required |
| PII (email, name) | flutter_secure_storage | ✅ Recommended |
| User preferences, theme | SharedPreferences | ⚠ OK (non-sensitive) |
| Cached data (posts, images) | Hive CE encrypted | ✅ With encryption |
<application <!-- Disable automatic ADB backup — prevents data extraction --> android:allowBackup="false" <!-- Custom backup rules if you need selective backup --> android:fullBackupContent="@xml/backup_rules" <!-- Restrict data extraction (Android 12+) --> android:dataExtractionRules="@xml/data_extraction_rules" <!-- Restrict app to debuggable=false in release --> android:debuggable="false" <!-- Network config for SSL pinning --> android:networkSecurityConfig="@xml/network_security_config" >
<full-backup-content> <!-- Exclude sensitive data from cloud backup --> <exclude domain="sharedpref" path="FlutterSecureStorage" /> <exclude domain="database" path="app_data.db" /> </full-backup-content>
<!-- Prevent iCloud backup of sensitive keychain items --> <key>NSAllowsArbitraryLoads</key> <false/> <!-- Require encrypted backups --> <key>UIFileSharingEnabled</key> <false/> <!-- Prevent file sharing via iTunes --> <key>LSSupportsOpeningDocumentsInPlace</key> <false/>
dependencies: dio: ^5.7.0
import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/services.dart'; class PinnedHttpClient { static Future<Dio> create() async { // Load pinned certificate from assets final certBytes = await rootBundle.load('assets/certs/api_cert.cer'); final cert = certBytes.buffer.asUint8List(); // Create SecurityContext with ONLY your certificate trusted final secCtx = SecurityContext(withTrustedRoots: false); secCtx.setTrustedCertificatesBytes(cert); // Create HttpClient using pinned context final httpClient = HttpClient(context: secCtx); // Reject connections that don't match pinned cert httpClient.badCertificateCallback = (X509Certificate cert, String host, int port) { // In prod: always return false — never trust bad certs return false; }; final dio = Dio(BaseOptions( baseUrl: Env.baseUrl, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), )); // Attach custom adapter with pinned client dio.httpClientAdapter = IOHttpClientAdapter( createHttpClient: () => httpClient, ); // Add auth interceptor dio.interceptors.add(AuthInterceptor()); return dio; } } // ─── Extract cert public key hash (use for SPKI pinning) ────── // $ openssl x509 -in cert.pem -pubkey -noout | openssl pkey -pubin -outform DER | openssl dgst -sha256 -binary | base64
<network-security-config> <base-config cleartextTrafficPermitted="false"> <trust-anchors> <!-- Only trust your own CA / Let's Encrypt --> <certificates src="@raw/api_cert" /> </trust-anchors> </base-config> <domain-config> <domain includeSubdomains="true">api.your-app.com</domain> <pin-set expiration="2026-12-31"> <!-- Primary pin (current cert SHA-256 SPKI hash) --> <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin> <!-- Backup pin (keep at least one backup!) --> <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin> </pin-set> </domain-config> <!-- NEVER allow debug-time CA trust to leak to release --> <debug-overrides> <trust-anchors> <certificates src="user" /> </trust-anchors> </debug-overrides> </network-security-config>
http://) — especially in debug builds or when usesCleartextTraffic=true was added for development and never removed. All production traffic must be HTTPS only.<!-- ✗ REMOVE this line if it exists in your manifest: --> <!-- android:usesCleartextTraffic="true" --> <!-- ✓ Set to false explicitly: --> <application android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config" >
<!-- ✗ NEVER allow arbitrary loads: --> <!-- <key>NSAllowsArbitraryLoads</key><true/> --> <!-- ✓ Enforce ATS (default, but make it explicit): --> <key>NSAppTransportSecurity</key> <dict> <!-- Require TLS 1.2+ for all connections --> <key>NSAllowsArbitraryLoads</key> <false/> <!-- If specific domains need exceptions (e.g., CDN): --> <key>NSExceptionDomains</key> <dict> <key>api.your-app.com</key> <dict> <key>NSRequiresCertificateTransparency</key><true/> <key>NSExceptionMinimumTLSVersion</key><string>TLSv1.3</string> </dict> </dict> </dict>
import 'package:webview_flutter/webview_flutter.dart'; class SecureWebView extends StatefulWidget { final String initialUrl; final List<String> allowedDomains; const SecureWebView({required this.initialUrl, required this.allowedDomains}); @override State<SecureWebView> createState() => _SecureWebViewState(); } class _SecureWebViewState extends State<SecureWebView> { late final WebViewController _controller; @override void initState() { super.initState(); _controller = WebViewController() // ✗ NEVER enable JavaScript unless absolutely required ..setJavaScriptMode(JavaScriptMode.disabled) // Whitelist-based navigation delegate ..setNavigationDelegate(NavigationDelegate( onNavigationRequest: (request) { final uri = Uri.parse(request.url); // Only allow HTTPS + whitelisted domains if (uri.scheme != 'https') { return NavigationDecision.prevent; } final isAllowed = widget.allowedDomains.any( (domain) => uri.host == domain || uri.host.endsWith('.$domain'), ); return isAllowed ? NavigationDecision.navigate : NavigationDecision.prevent; }, onWebResourceError: (error) { // Never expose internal error details to the page debugPrint('WebView error: ${error.errorCode}'); }, )) // Disable mixed content (HTTP in HTTPS context) ..loadRequest(Uri.parse(widget.initialUrl)); } }
BiometricOnly: false which allows PIN/pattern fallback) or not validating available biometric strength allows bypass via weaker authenticators.dependencies: local_auth: ^2.3.0
import 'package:local_auth/local_auth.dart'; class BiometricService { final _auth = LocalAuthentication(); Future<bool> canAuthenticate() async { if (!await _auth.canCheckBiometrics()) return false; final available = await _auth.getAvailableBiometrics(); // Require strong biometrics (fingerprint or face) return available.any((b) => b == BiometricType.fingerprint || b == BiometricType.face || b == BiometricType.strong, ); } Future<bool> authenticate() async { if (!await canAuthenticate()) { throw Exception('Biometric authentication not available'); } return _auth.authenticate( localizedReason: 'Authenticate to access your account', options: const AuthenticationOptions( biometricOnly: true, // ✓ NO PIN/pattern fallback sensitiveTransaction: true, stickyAuth: true, // keep auth dialog active if app backgrounded useErrorDialogs: true, ), ); } } // ─── Android: declare in AndroidManifest.xml ────────────────── // <uses-permission android:name="android.permission.USE_BIOMETRIC" />
dependencies: flutter_appauth: ^8.0.1
import 'package:flutter_appauth/flutter_appauth.dart'; class AuthService { static const _appAuth = FlutterAppAuth(); // ─── Authorization Code + PKCE flow ────────────────────────── Future<AuthorizationTokenResponse?> signIn() async { return await _appAuth.authorizeAndExchangeCode( AuthorizationTokenRequest( 'YOUR_CLIENT_ID', 'com.your.app:/oauth2callback', // use App Links, not custom scheme issuer: 'https://accounts.your-idp.com', scopes: ['openid', 'profile', 'email'], preferEphemeralSession: true, // no cookie persistence // PKCE is enabled by default in flutter_appauth // code_verifier & code_challenge generated automatically additionalParameters: { 'prompt': 'consent', // always show consent }, ), ); } // ─── Refresh token securely ─────────────────────────────────── Future<TokenResponse?> refreshToken(String refreshToken) async { return await _appAuth.token( TokenRequest( 'YOUR_CLIENT_ID', 'com.your.app:/oauth2callback', refreshToken: refreshToken, issuer: 'https://accounts.your-idp.com', scopes: ['openid', 'profile'], ), ); } }
⚠️ Use Android App Links / iOS Universal Links (HTTPS-based) instead of custom URL schemes (myapp://) to prevent scheme hijacking. Custom schemes can be registered by any app on the device.
class DeepLinkValidator { // Allowed deep link hosts static const _allowedHosts = {'your-app.com', 'app.your-app.com'}; // Allowed path prefixes — reject unknown paths static const _allowedPaths = { '/product/', '/user/', '/order/', '/share/', }; static bool validate(Uri uri) { // 1. Only accept HTTPS deep links (App Links / Universal Links) if (uri.scheme != 'https') return false; // 2. Validate host against allowlist if (!_allowedHosts.contains(uri.host)) return false; // 3. Validate path starts with known prefix final validPath = _allowedPaths.any((p) => uri.path.startsWith(p)); if (!validPath) return false; // 4. Validate query parameters (no path traversal) for (final value in uri.queryParameters.values) { if (value.contains('..') || value.contains('/')) return false; } return true; } // ─── Usage in main.dart ─────────────────────────────────────── // Stream<Uri?> incoming = app_links.uriLinkStream; // incoming.listen((uri) { // if (uri != null && DeepLinkValidator.validate(uri)) { // router.go(uri.path, extra: uri.queryParameters); // } // }); }
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.your.app",
"sha256_cert_fingerprints": [
"YOUR_APP_SIGNING_CERT_SHA256"
]
}
}]
dependencies: flutter_jailbreak_detection: ^1.10.0 safe_device: ^1.1.4
import 'package:flutter_jailbreak_detection/flutter_jailbreak_detection.dart'; import 'package:safe_device/safe_device.dart'; class DeviceIntegrityChecker { static Future<DeviceCheckResult> check() async { final checks = await Future.wait([ FlutterJailbreakDetection.jailbroken, // iOS jailbreak FlutterJailbreakDetection.developerMode, // Android dev mode SafeDevice.isRealDevice, // emulator check SafeDevice.isSafeDevice, // combined safe check ]); final isJailbroken = checks[0] as bool; final isDevMode = checks[1] as bool; final isRealDevice = checks[2] as bool; final isSafe = checks[3] as bool; return DeviceCheckResult( isCompromised: isJailbroken || !isSafe, isEmulator: !isRealDevice, isDeveloperMode: isDevMode, ); } } // ─── Usage in app startup ───────────────────────────────────── Future<void> checkDeviceSecurity(BuildContext context) async { final result = await DeviceIntegrityChecker.check(); if (result.isCompromised) { // Log the event (for analytics) but don't reveal WHY to attacker await AnalyticsService.logEvent('compromised_device_detected'); // Show generic security warning — don't crash silently if (context.mounted) { showDialog(context: context, builder: (_) => const SecurityWarningDialog()); } } }
⚠️ Detection alone is not sufficient — a sophisticated attacker with Frida can hook and bypass these checks. Use alongside code obfuscation and Play Integrity API (Android) / DeviceCheck (iOS) for server-side verification.
import 'package:flutter/foundation.dart'; class AppLogger { // ✓ kReleaseMode is a compile-time constant — dead code eliminated in prod static void debug(Object? msg) { if (kDebugMode) debugPrint('[DEBUG] $msg'); } static void error(Object? msg) { if (kDebugMode) debugPrint('[ERROR] $msg'); // In release: send to crash reporting service (Sentry, Firebase Crashlytics) if (kReleaseMode) CrashReporter.log(msg); } // ✗ NEVER: print(response.data) — exposes full API response in console // ✓ ALWAYS: AppLogger.debug('Response received: ${response.statusCode}') } // ─── Security checks that only apply in release ─────────────── void applySecurityPolicies() { if (kReleaseMode) { // Disable Flutter DevTools Observatory // This is automatic in release builds // Verify this is not a debug build that slipped to prod assert(!kDebugMode, 'Do not publish debug builds!'); } }
# ✗ NEVER publish debug or profile builds # flutter build apk (defaults to debug!) # ✓ ALWAYS use --release flag $ flutter build apk --release $ flutter build appbundle --release # Google Play $ flutter build ipa --release # iOS App Store $ flutter build web --release --dart-define=IS_PROD=true # Verify the build mode in the output $ strings app-release.apk | grep -i "debug\|profile" → Should not contain debug build indicators
dependencies: flutter_windowmanager: ^0.3.0
import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter_windowmanager/flutter_windowmanager.dart'; class ScreenshotProtection { // ─── Enable on sensitive screens ───────────────────────────── static Future<void> enable() async { if (Platform.isAndroid) { // Android: FLAG_SECURE blocks screenshots AND screen recording await FlutterWindowManager.addFlags( FlutterWindowManager.FLAG_SECURE, ); } else if (Platform.isIOS) { // iOS: overlay an opaque view when screen is captured // Use platform channel to call UITextField.isSecureTextEntry approach await const MethodChannel('screenshot_protection') .invokeMethod('enable'); } } // ─── Disable when leaving sensitive screens ─────────────────── static Future<void> disable() async { if (Platform.isAndroid) { await FlutterWindowManager.clearFlags( FlutterWindowManager.FLAG_SECURE, ); } else if (Platform.isIOS) { await const MethodChannel('screenshot_protection') .invokeMethod('disable'); } } } // ─── Usage: in sensitive screen's initState / dispose ───────── // @override void initState() { super.initState(); ScreenshotProtection.enable(); } // @override void dispose() { ScreenshotProtection.disable(); super.dispose(); }
# ─── Android APK / AAB ─────────────────────────────────────── $ flutter build appbundle \ --release \ --obfuscate \ --split-debug-info=build/debug-symbols/android/ # --split-debug-info separates debug symbols for crash reporting # Store debug symbols SECURELY — not in the APK! # ─── iOS IPA ───────────────────────────────────────────────── $ flutter build ipa \ --release \ --obfuscate \ --split-debug-info=build/debug-symbols/ios/ # ─── Upload debug symbols to crash reporter ───────────────── # Firebase Crashlytics (Android): $ firebase crashlytics:symbols:upload \ --app=APP_ID \ build/debug-symbols/android/app.android-arm64.symbols # ─── Verify obfuscation worked ─────────────────────────────── $ strings app-release.apk | grep -E "^[A-Z][a-z]+[A-Z]" # Class names should be short: a, b, c, aa, etc. NOT: UserRepository
android {
buildTypes {
release {
minifyEnabled true // Enable ProGuard/R8
shrinkResources true // Remove unused resources
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}
# ─── Dart built-in security audit ──────────────────────────── $ dart pub audit → Found X security advisories (lists CVE numbers and fixes) # ─── Check for outdated packages ────────────────────────────── $ flutter pub outdated → Lists all packages with newer versions available # ─── Upgrade to latest safe versions ───────────────────────── $ flutter pub upgrade --major-versions # major version bumps $ flutter pub upgrade # minor/patch updates # ─── Verify pubspec.lock is committed (reproducible builds) ── $ git status pubspec.lock → (tracked and committed) # ─── Check for packages with no recent activity ────────────── $ dart pub outdated --show-all | grep "discontinued\|unlisted" → (investigate any discontinued packages)
dependencies:
flutter:
sdk: flutter
# ✓ Pin to specific compatible version ranges
dio: ^5.7.0 # HTTP client
flutter_secure_storage: ^9.2.2
flutter_appauth: ^8.0.1
local_auth: ^2.3.0
# ✗ AVOID: very wide ranges or no constraint
# some_package: any ← never do this
# some_package: >=1.0.0 ← too wide
# Check package origins — prefer flutter/dart org maintained packages
# Verify publisher: pub.dev → package → "Publisher: flutter.dev" or "dart.dev"
main.dart.js (often 5-10MB) exposes the entire Dart SDK in JavaScript form. Missing CSP, HSTS, and frame protection leaves the app vulnerable to XSS and clickjacking.server {
listen 443 ssl http2;
server_name your-flutter-app.com;
root /var/www/flutter-app/build/web;
# ─── SPA routing for Flutter Web ────────────────────────
location / {
try_files $uri $uri/ /index.html;
}
# ─── Security headers ────────────────────────────────────
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# ─── CSP for Flutter Web (canvas-based renderer) ─────────
add_header Content-Security-Policy
"default-src 'self';
script-src 'self' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
connect-src 'self' https://api.your-app.com wss://api.your-app.com;
worker-src 'self' blob:;
frame-src 'none';
object-src 'none';"
always;
# ─── Cache control ────────────────────────────────────────
location ~* \.(js|css|wasm|png|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Block source maps in production
location ~* \.map$ { deny all; return 404; }
}
# Production Flutter Web build $ flutter build web \ --release \ --dart-define=IS_PROD=true \ --dart-define-from-file=.env.production \ --web-renderer canvaskit \ # or 'html' — canvaskit harder to scrape --source-maps=false # no source maps in production # Deploy build/web/ to Nginx root $ rsync -avz build/web/ user@server:/var/www/flutter-app/build/web/
allow read, write: if true — exposing all data publicly.rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { // ✗ NEVER EVER in production: // match /{document=**} { allow read, write: if true; } // ─── Users: own data only ───────────────────────────────── match /users/{userId} { allow read: if request.auth != null && request.auth.uid == userId; allow create: if request.auth != null && request.auth.uid == userId && validUserData(request.resource.data); allow update: if request.auth != null && request.auth.uid == userId && !request.resource.data.diff(resource.data).affectedKeys() .hasAny(['role', 'createdAt']); // can't self-promote role allow delete: if false; // no self-delete } // ─── Posts: authenticated read, owner write ─────────────── match /posts/{postId} { allow read: if request.auth != null; allow create: if request.auth != null && request.resource.data.authorId == request.auth.uid && validPostData(request.resource.data); allow update: if request.auth != null && resource.data.authorId == request.auth.uid; allow delete: if request.auth != null && resource.data.authorId == request.auth.uid; } // ─── Admin operations — server-side only ───────────────── match /admin/{document=**} { allow read, write: if false; // ONLY via Firebase Admin SDK } // ─── Helper functions ───────────────────────────────────── function validUserData(data) { return data.keys().hasAll(['name', 'email']) && data.name is string && data.name.size() <= 100 && data.email is string; } function validPostData(data) { return data.keys().hasAll(['title', 'authorId']) && data.title is string && data.title.size() <= 500; } } }
rules_version = '2'; service firebase.storage { match /b/{bucket}/o { // User profile images: owner can write, authenticated can read match /users/{userId}/profile/{fileName} { allow read: if request.auth != null; allow write: if request.auth != null && request.auth.uid == userId && request.resource.size < 5 * 1024 * 1024 // 5MB max && request.resource.contentType.matches('image/.*'); } // Deny all other access by default match /{allPaths=**} { allow read, write: if false; } } }
# Test rules before deploying (Firebase Emulator Suite) $ firebase emulators:start $ firebase emulators:exec --only firestore 'dart test test/security_rules_test.dart' # Deploy rules to production $ firebase deploy --only firestore:rules $ firebase deploy --only storage:rules