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
| Dimension | Web | Mobile |
|---|---|---|
| Code access | Server-side code is hidden; client JS is visible but ephemeral | Full binary lives on the device; can be extracted and decompiled at leisure |
| Storage | Cookies (httpOnly, Secure), sessionStorage | Files on a filesystem the user (or malware) can read |
| Network | HTTPS enforced by browsers | App controls its own HTTP stack; proxies like Charles/mitmproxy can intercept if pinning is absent |
| Runtime | Browser sandbox | OS sandbox, but root/jailbreak removes it entirely |
| Distribution | Served fresh on every request | Installed once; updates are asynchronous and optional |
Threat Matrix
| Threat | Attack Vector | Impact | Primary Mitigation |
|---|---|---|---|
| Network interception | Proxy tools (mitmproxy, Charles) on compromised Wi-Fi | Token theft, data exfiltration | Certificate pinning, TLS 1.3 |
| Reverse engineering | Extracting APK/IPA, decompiling Dart snapshot | Leaked API keys, business logic exposure | Obfuscation, server-side secrets |
| Local data theft | Device backup extraction, rooted filesystem read | PII leakage, token reuse | Encrypted storage, data protection levels |
| Session hijacking | Stolen refresh token from insecure storage | Full account takeover | Keychain/Keystore storage, token rotation |
| Insecure storage | SharedPreferences in plaintext XML | Credential theft | flutter_secure_storage, encrypted databases |
| Tampering | Patching the binary, Frida hooking | Bypassed paywalls, forged requests | Integrity 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 Type | Storage | Why |
|---|---|---|
| Auth tokens (access, refresh) | flutter_secure_storage | Stolen tokens = full account takeover |
| PII (email, phone, SSN) | flutter_secure_storage or encrypted DB | Regulatory and liability exposure |
| Encryption keys | flutter_secure_storage | Key leakage defeats encryption entirely |
| User preferences (theme, locale) | SharedPreferences | No security value; no encryption overhead |
| Cached API responses (non-sensitive) | Hive / drift / file cache | Performance 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_KEYCode 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-infoWhat Obfuscation Does and Does Not Protect
| Protected | Not Protected |
|---|---|
Class names (renamed to a, b, c...) | String literals ("https://api.example.com") |
| Method names | Integer/boolean constants |
| Field names | Control flow and algorithm logic |
| Library structure | Asset 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;
}
}Deep Link Hijacking Prevention
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,LineNumberTableEnable R8 in android/gradle.properties:
android.enableR8=trueiOS 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>| Level | Files Accessible When |
|---|---|
NSFileProtectionComplete | Only while device is unlocked |
NSFileProtectionCompleteUnlessOpen | Unlocked, or if file handle was opened before lock |
NSFileProtectionCompleteUntilFirstUserAuthentication | After first unlock (default) |
NSFileProtectionNone | Always (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.
| Category | Check | Priority |
|---|---|---|
| Storage | Auth tokens stored in flutter_secure_storage, not SharedPreferences | Critical |
| Storage | Database passphrase stored in Keychain/Keystore, not hardcoded | Critical |
| Storage | No PII in plaintext files or unencrypted databases | Critical |
| Network | Certificate pinning enabled for all API endpoints | High |
| Network | ATS enabled on iOS; cleartext traffic disabled on Android | High |
| Network | No API secrets shipped in the binary | Critical |
| Binary | --obfuscate and --split-debug-info used for release builds | High |
| Binary | Debug symbols uploaded to crash reporting service | High |
| Binary | ProGuard/R8 rules configured and tested | Medium |
| Auth | Refresh tokens rotated on each use; old tokens revoked server-side | High |
| Auth | Session timeout implemented for sensitive apps | Medium |
| Auth | OAuth callbacks use verified/universal links, not custom schemes | High |
| Runtime | Root/jailbreak detection active with appropriate response | Medium |
| Runtime | FLAG_SECURE enabled on sensitive screens | Medium |
| Runtime | Background snapshot obscured on iOS | Medium |
| Runtime | No sensitive data in logs (avoid_print lint enabled) | High |
| Privacy | Full data erasure method implemented and tested | High |
| Privacy | Data retention TTLs set on cached PII | Medium |
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.