The JWT Token Incident: Why Your Flutter Cache Isn't Secure
This is Part 2 of a 3-part series.
π Missed Part 1? Read When SharedPreferences Fails: Architecting Resilient Cache Infrastructure where we built the fault-tolerant foundation with Circuit Breakers and LRU Eviction.
Here's what we built so far:
- β Fault-tolerant storage with automatic fallbacks
- β Memory-efficient LRU eviction (52MB vs 380MB)
- β Type-safe serialization and clean interfaces
But resilience doesn't mean security.
During our pre-production security audit, a pentester showed us how to extract every JWT token, API key,
and user credential from our "secure" appβin under 60 seconds:
adb shell
cat /data/data/com.yourapp/shared_prefs/FlutterSharedPreferences.xml
Everything was in plain text.
The auditor then used the extracted JWT token to authenticate API requests from Postman and access user
private data. Finding: P0 Critical - Authentication tokens stored unencrypted.
The threat model was clear:
- β SharedPreferences: Plain text XML (exposed on rooted devices)
- β ADB backups: Include SharedPrefs by default (no root required)
- β Forensic tools: Can extract plain text data even from powered-off devices
In this part, we'll architect a defense-in-depth security layer:
- π Understanding iOS Keychain and Android KeyStore (hardware-backed encryption)
- π How Secure Enclave and TrustZone protect keys even on rooted devices
- π The 50-100x performance tax (and why it's worth it for tokens, not preferences)
- π Real-world attack scenarios (root access, ADB backups, forensic tools)
- π Exception design for security-aware error handling
By the end, you'll know exactly what to encrypt, how to encrypt it, and how to handle failures gracefully.
Full source code available on GitHub
Part 5: Security Architecture - When SharedPreferences Isn't Enough
The JWT Token Incident (Pre-Production Audit)
During our security audit, the pentester showed us this:
# On a rooted Android device
adb shell
cd /data/data/com.yourapp/shared_prefs/
cat FlutterSharedPreferences.xml
<?xml version="1.0" encoding="utf-8"?>
<map>
<string name="flutter.jwt_token">eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...</string>
<string name="flutter.api_key">sk_live_51HqK9...</string>
<string name="flutter.user_email">user@example.com</string>
</map>
Everything was in plain text.
The auditor then demonstrated:
- Extracting the JWT token
- Using it to authenticate API requests from Postman
- Accessing the user's private data
Finding: P0 Critical - Authentication tokens stored unencrypted.
Understanding the Threat Model
What SharedPreferences Protects Against:
- β Other apps reading your data (sandboxed by OS)
- β Accidental deletion (persists across app restarts)
What SharedPreferences DOES NOT Protect Against:
- β Root/jailbreak access (user has full filesystem access)
- β Device backup leaks (ADB backups include SharedPrefs by default)
- β Malware with elevated permissions
- β Physical device access (forensic tools like Cellebrite, GrayKey)
- β Screenshot/screen recording attacks (if displayed in UI)
The Solution: Explicit Security Tiers
We designed the API to force a conscious decision about security:
// Tier 1: Public Cache (Fast, Non-Encrypted)
// For preferences, themes, and non-sensitive data
await Cache.set('theme_mode', 'dark');
await Cache.set('language', 'en');
await Cache.set('last_sync_timestamp', DateTime.now().toIso8601String());
// Tier 2: Secure Cache (Slow, Hardware-Encrypted)
// For sensitive data requiring encryption
await Cache.secure.set('jwt_token', token);
await Cache.secure.set('api_key', apiKey);
await Cache.secure.set('biometric_signature', signature);
Why explicit? We considered auto-detecting sensitive keys (e.g., if key contains "token" or "password"), but rejected it:
-
False positives:
"password_hint"doesn't need encryption -
False negatives:
"auth"is vagueβcould be sensitive or not - Magic is dangerous: Security should be visible in code reviews
Platform Internals: How Secure Storage Works
iOS: Keychain Services Architecture
Key Properties:
- Hardware-backed: Keys stored in Secure Enclave (isolated coprocessor)
- Tied to device UID: Can't copy Keychain data to another device
- Access control: Can require Face ID/Touch ID for key access
-
Persistence: Survives app reinstall (unless
kSecAttrAccessibleWhenUnlockedThisDeviceOnly) - iCloud sync: Can be disabled to prevent cloud backup
What happens when you write to Keychain:
await secureStorage.write(key: 'jwt_token', value: token);
Behind the scenes:
- Flutter plugin calls iOS Keychain API (
SecItemAdd) - Keychain API sends request to
securityddaemon (IPC via XPC) -
securitydcommunicates with Secure Enclave - Secure Enclave generates encryption key (never leaves the coprocessor)
- Data encrypted with AES-256-GCM
- Encrypted blob stored in SQLite database at
/var/Keychains/keychain-2.db - Encryption key remains in Secure Enclave (inaccessible to app)
Configuration:
const storage = FlutterSecureStorage(
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
// Data accessible after first device unlock, doesn't sync to iCloud
),
);
Accessibility Options:
| Option | When Accessible | Survives Reboot? | iCloud Sync? | Use Case |
|---|---|---|---|---|
unlocked |
Device unlocked | β Yes | β Yes | User preferences |
unlocked_this_device |
Device unlocked | β Yes | β No | Session tokens |
first_unlock |
After first unlock | β Yes | β Yes | Background refresh tokens |
first_unlock_this_device |
After first unlock | β Yes | β No | Recommended for tokens |
passcode_set_this_device |
Passcode required | β Yes | β No | Extra sensitive data |
We use first_unlock_this_device because:
- Data survives device reboot (user doesn't need to re-login)
- Data NOT synced to iCloud (no cloud backup exposure)
- Background tasks can access after first unlock (push notifications work)
Android: KeyStore System Architecture
Key Properties:
- Hardware-backed (when available): Uses ARM TrustZone (TEE) or StrongBox
- Key isolation: Encryption keys never exposed to app process
- Software fallback: On older devices (<API 23), keys stored in encrypted user storage
- Root-resistant: Hardware-backed keys survive even on rooted devices (keys in TEE)
What happens when you write to SecureStorage on Android:
await secureStorage.write(key: 'jwt_token', value: token);
Behind the scenes (API 23+):
- Flutter plugin uses
EncryptedSharedPreferences -
EncryptedSharedPreferencesretrieves master key from KeyStore - If master key doesn't exist, KeyStore generates one in TrustZone
- Data encrypted with AES-256-GCM using master key
- Encrypted data stored in SharedPreferences XML
- Master key never leaves TrustZone (hardware-isolated)
Under the hood (what gets written to disk):
<!-- /data/data/com.yourapp/shared_prefs/FlutterSecureStorage.xml -->
<string name="__androidx_security_crypto_encrypted_prefs_key_keyset__">
AeS7K9mP2qL4vN8xR5tY1wZ3bC6dF... (encrypted with KeyStore master key)
</string>
<string name="__androidx_security_crypto_encrypted_prefs_value_keyset__">
X2nQ8vL5jH4kM7pA9sD3fG6hJ0mN... (encrypted with KeyStore master key)
</string>
<string name="VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGtleQ==">
AeadAes256GcmHkdfSha256... (encrypted 'jwt_token' value)
</string>
Even if an attacker extracts this XML:
- The data is encrypted (AES-256-GCM)
- The encryption key is in TrustZone (hardware-isolated)
- Without the device's hardware UID, the data is useless
Hardware Attestation:
On modern Android devices (API 24+), you can verify key storage:
final keyInfo = KeyFactory.getInstance(algorithm)
.getKeySpec(key, KeyInfo.class);
if (keyInfo.isInsideSecureHardware()) {
print('β
Key protected by hardware (TrustZone/StrongBox)');
} else {
print('β οΈ Key in software (older device or emulator)');
}
The Performance Tax
Secure storage is 50-100x slower than SharedPreferences:
Why so slow?
- Cryptographic operations: AES-256 encryption/decryption on every call
-
Inter-process communication:
- iOS: XPC calls to
securityddaemon (context switch) - Android: Binder IPC to KeyStore service
- iOS: XPC calls to
- Hardware round-trip: Calls into TrustZone or Secure Enclave require switching execution environments
The IPC Overhead:
Your App (Dart) β Flutter Plugin (Native) β System Service β Hardware β Response
β β β β
5ms 20ms (context switch) 50ms 80ms (crypto)
βββββββββ
155ms total
Compare to SharedPreferences:
Your App (Dart) β Flutter Plugin (Native) β File I/O β Response
β β β
1ms 2ms 5ms
ββββ
8ms total
Real App Startup Performance:
// β BAD: Loading everything from secure storage
await secureCache.get('user_id'); // 120ms
await secureCache.get('user_name'); // 115ms
await secureCache.get('user_email'); // 118ms
await secureCache.get('jwt_token'); // 130ms
// Total: ~480ms (delays first screen render!)
// β
GOOD: Only secrets in secure storage
await cache.get('user_id'); // 3ms (SharedPrefs)
await cache.get('user_name'); // 2ms (SharedPrefs)
await cache.get('user_email'); // 3ms (SharedPrefs)
await secureCache.get('jwt_token'); // 125ms (SecureStorage)
// Total: ~133ms (72% faster!)
Decision Matrix: Which Driver to Use
// β
Use SharedPreferences (fast, plain text) for:
await Cache.set('user_preferences', json, driver: 'shared_prefs');
await Cache.set('cached_api_response', response, driver: 'shared_prefs');
await Cache.set('feature_flags', flags, driver: 'shared_prefs');
await Cache.set('theme_mode', 'dark', driver: 'shared_prefs');
// Characteristics: High-frequency access, non-sensitive, ok if exposed
// β
Use SecureStorage (slow, encrypted) for:
await Cache.set('jwt_token', token, driver: 'secure_storage');
await Cache.set('oauth_refresh_token', refresh, driver: 'secure_storage');
await Cache.set('credit_card_token', cardToken, driver: 'secure_storage');
await Cache.set('encryption_key', key, driver: 'secure_storage');
await Cache.set('biometric_signature', sig, driver: 'secure_storage');
// Characteristics: Low-frequency access, highly sensitive, must be encrypted
// β
Use Memory (fast, ephemeral) for:
await Cache.set('session_state', state, driver: 'memory');
await Cache.set('pagination_cursor', cursor, driver: 'memory');
await Cache.set('search_results', results, driver: 'memory');
// Characteristics: Temporary data, doesn't need persistence
// β Never store in any driver:
// - Raw passwords (use hashing + secure storage for tokens only)
// - Social Security Numbers (store on backend only)
// - Full credit card numbers (use tokenization services like Stripe)
// - PII without user consent (GDPR/CCPA compliance)
Real-World Attack Scenarios We Protect Against
Attack 1: Rooted Device + Malware
# Attacker with root access
su
cat /data/data/com.yourapp/shared_prefs/*.xml
# β
SharedPrefs: Exposed (plain text XML)
# β
SecureStorage: Still encrypted (master key in hardware TEE)
Even with root, the attacker cannot extract the encryption keys from TrustZone/Secure Enclave.
Why hardware-backed encryption survives root:
Root access gives you:
β
Read filesystem (all SharedPreferences XML)
β
Inject code into running processes
β
Modify system files
Root DOES NOT give you:
β Access to Secure Enclave/TrustZone (hardware-isolated)
β Extraction of KeyStore master keys (protected by hardware UID)
β Ability to decrypt data without proper hardware
Attack 2: ADB Backup Extraction (No Root Required)
# Attacker with physical device access (USB debugging enabled)
adb backup -f backup.ab com.yourapp
dd if=backup.ab bs=24 skip=1 | openssl zlib -d > backup.tar
tar xf backup.tar
cat apps/com.yourapp/sp/*.xml
# β
SharedPrefs: Exposed (included in backup by default)
# β
SecureStorage: Protected (excluded via android:allowBackup="false")
Protection strategy:
In AndroidManifest.xml:
<application
android:allowBackup="false"
android:fullBackupContent="false">
This prevents ADB backups from including app data. For user-requested backups, use Android Auto Backup with exclusion rules:
<!-- res/xml/backup_rules.xml -->
<full-backup-content>
<exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
</full-backup-content>
Attack 3: Forensic Tools (Police/Government)
Professional forensic tools (Cellebrite UFED, GrayKey, Magnet AXIOM) can extract:
- β SharedPreferences data (plain text, easy to parse)
- β App databases (SQLite files)
- β App files and media
- β Keychain/KeyStore data without device passcode (hardware-protected)
- β Data from powered-off devices (encryption keys in volatile memory)
How forensic tools work:
- Physical extraction: Connect device via JTAG, exploit bootloader
- Logical extraction: Use vendor debug protocols (iOS: lockdownd, Android: ADB)
- Filesystem analysis: Parse SQLite, XML, binary files
- Keychain/KeyStore attempt: Try to extract keys (requires device passcode)
What stops them:
- iOS: Secure Enclave requires device passcode to release keys. Without passcode, Keychain is useless.
- Android: TrustZone-backed keys require device unlock. StrongBox (Pixel 3+) adds hardware rate limiting (10 attempts/second max).
Defense in Depth: Security Layers
The security architecture is built on four defensive layers, forcing an attacker to bypass multiple distinct barriers:
-
Layer 1: App Sandbox
- Protection: Prevents other apps from reading data.
- Vulnerability: Can be bypassed by Root access or physical extraction.
-
Layer 2: Encrypted Storage (AES-256)
- Protection: Data is encrypted at rest on the disk.
- Vulnerability: Can be bypassed if the Master Key is extracted.
-
Layer 3: Hardware-Backed Keys
- Protection: Keys are isolated in Secure Enclave/TEE (Hardware level).
- Vulnerability: Extremely difficult to bypass (requires expensive hardware attacks).
-
Layer 4: Device Passcode + Biometrics
- Protection: User access control (Face ID, Touch ID).
- Vulnerability: Social engineering or biometric spoofing.
Each layer protects against a different threat vector. SecureStorage specifically protects against offline attacks (device lost/stolen, forensic analysis, malware).
Key Insight: Security architecture isn't about choosing "the secure option"βit's about matching threat models to controls. SharedPreferences is perfectly secure for themes and preferences. SecureStorage is essential for tokens and keys. The architecture makes this distinction explicit and code-reviewable.
Part 11: Exception Design - Meaningful Error Messages for Security
Generic exceptions (Exception: error) hide root causes and make debugging impossible. Security failures need explicit, actionable errors.
We implemented a specialized hierarchy:
// lib/core/cache/domain/exceptions/cache_exceptions.dart
/// Base exception for all cache operations
abstract class CacheException implements Exception {
final String message;
final Object? cause;
final StackTrace? stackTrace;
const CacheException({
required this.message,
this.cause,
this.stackTrace,
});
@override
String toString() =>
'$runtimeType: $message${cause != null ? ' (caused by: $cause)' : ''}';
}
/// Key not found in cache
class CacheMissException extends CacheException {
final String key;
const CacheMissException({
required this.key,
String? message,
super.cause,
super.stackTrace,
}) : super(message: message ?? 'Cache miss for key: $key');
}
/// Failed to serialize/deserialize data
class CacheSerializationException extends CacheException {
final Type type;
final dynamic value;
const CacheSerializationException({
required this.type,
this.value,
super.message = 'Failed to serialize/deserialize type',
super.cause,
super.stackTrace,
});
}
/// Storage driver unavailable or failed
class CacheDriverException extends CacheException {
final String driverName;
const CacheDriverException({
required this.driverName,
super.message = 'Cache driver failure',
super.cause,
super.stackTrace,
});
}
/// Data expired (TTL exceeded)
class CacheTTLExpiredException extends CacheException {
final String key;
final DateTime expiredAt;
const CacheTTLExpiredException({
required this.key,
required this.expiredAt,
String? message,
super.cause,
super.stackTrace,
}) : super(message: message ?? 'Cache entry expired for key: $key');
}
Why Security-Aware Exceptions Matter
Scenario: Keychain Access Failure on iOS
Without specific exceptions:
try {
final token = await secureCache.get('jwt_token');
} catch (e) {
print('Error: $e'); // What went wrong? Keychain? Network? Bug?
// User sees: "Something went wrong"
}
With security-aware exceptions:
try {
final token = await secureCache.get('jwt_token');
} on CacheDriverException catch (e) {
if (e.driverName == 'secure_storage') {
// Keychain access failed
if (e.cause.toString().contains('errSecInteractionNotAllowed')) {
// Device is locked, background access denied
showNotification('Please unlock your device to continue');
} else if (e.cause.toString().contains('errSecAuthFailed')) {
// User denied Face ID/Touch ID
showAlert('Biometric authentication required');
} else {
// Generic Keychain failure - fall back to re-login
logout();
showAlert('Session expired. Please log in again.');
}
}
} on CacheTTLExpiredException catch (e) {
// Token expired - refresh it
final newToken = await api.refreshToken();
await secureCache.set('jwt_token', newToken, ttl: Duration(hours: 1));
}
Common Keychain/KeyStore Error Codes
iOS Keychain Errors:
| Error Code | Name | Meaning | Recovery Strategy |
|---|---|---|---|
-25300 |
errSecItemNotFound |
Key doesn't exist | Expected - user not logged in |
-25308 |
errSecInteractionNotAllowed |
Device locked | Wait for unlock, show notification |
-26275 |
errSecAuthFailed |
Biometric denied | Retry or fallback to passcode |
-34018 |
(Undocumented) | iOS 10 Keychain bug | Known Apple bug - retry after delay |
Android KeyStore Errors:
| Exception | Meaning | Recovery Strategy |
|---|---|---|
KeyStoreException |
Generic failure | Log and fallback to re-login |
UserNotAuthenticatedException |
User auth required | Prompt for biometric/PIN |
KeyPermanentlyInvalidatedException |
Biometric changed | Clear all secure data, force re-login |
Production Example: Handling KeyStore Failures
Future<String?> getSecureToken() async {
try {
return await Cache.secure.get<String>('jwt_token');
} on CacheDriverException catch (e) {
if (e.driverName == 'secure_storage') {
// Log to analytics
Analytics.track('keystore_failure', {
'error': e.message,
'platform': Platform.isIOS ? 'ios' : 'android',
});
// Check if we can recover
if (e.cause is UserNotAuthenticatedException) {
// User needs to authenticate
final authenticated = await promptBiometric();
if (authenticated) {
// Retry after successful auth
return await Cache.secure.get<String>('jwt_token');
}
}
// Cannot recover - force re-login
await logout();
return null;
}
rethrow;
}
}
Usage with specific error handling:
try {
final user = await Cache.get<User>('current_user');
} on CacheMissException catch (e) {
// Fetch from API
final user = await api.fetchUser();
await Cache.set('current_user', user);
} on CacheTTLExpiredException catch (e) {
// Refresh expired data
print('Data expired at: ${e.expiredAt}');
final user = await api.fetchUser();
await Cache.set('current_user', user, ttl: Duration(hours: 1));
} on CacheSerializationException catch (e) {
// Data corrupt, clear it
print('Corrupt data for type: ${e.type}');
await Cache.remove('current_user');
} on CacheDriverException catch (e) {
// Storage failed, use fallback
print('Driver ${e.driverName} failed');
// Circuit breaker already handled this, but we can log it
}
Production Benefit:
When looking at Crashlytics or Sentry, we can instantly distinguish between:
-
CacheSerializationExceptionβ Data corrupt, investigate data format changes -
CacheDriverException(secure_storage)β Keychain failure, check OS version distribution -
CacheMissExceptionβ Expected behavior (cache empty) -
CacheTTLExpiredExceptionβ Expected behavior (data expired)
Security Insight: Exceptions are part of your attack surface. Don't leak sensitive information in error messages:
// β BAD: Leaks token structure
throw Exception('Invalid JWT token: $token');
// β
GOOD: No sensitive data in message
throw CacheSerializationException(
type: String,
message: 'Invalid token format',
// token not included in exception
);
What's Next: Scaling to Production
We've built a secure cache that protects sensitive data with hardware-backed encryption:
- β JWT tokens encrypted in iOS Keychain (Secure Enclave)
- β API keys protected in Android KeyStore (TrustZone)
- β Defense-in-depth against root access, forensic tools, and malware
- β
Security-aware exception handling (
CacheDriverException, etc.) - β Explicit tier separation (public vs secure cache)
But production systems face challenges beyond security:
What happens when you need to sync 500 user preferences after login?
Our first implementation:
- β Sequential writes: 2.5 seconds (users staring at loading spinner)
- β Naive parallel: App crashes after 200+ items (
TransactionTooLargeException) - β Stale UI: User updates profile β cache updates β screen doesn't refresh
We had a secure cache. But it wasn't production-ready yet.
In Part 3, we'll tackle production-scale challenges:
- β‘ Batching Against Platform Limits: Android Binder (1MB), iOS XPC constraints
- β‘ Optimistic Locking: Solving race conditions with versioning
- β‘ Observer Pattern: Making cache changes observable to UI (reactive state)
- β‘ Production Metrics: Circuit breaker health checks and monitoring
- β‘ Testing Architecture: 3-layer strategy (unit, integration, widget tests)
- β‘ Lessons Learned: What worked, what we'd change
From 0.3% crash rate to zero. From 2.5 seconds to 140ms. From manual UI updates to reactive state.
This is where theory meets production.
π Continue to Part 3: From 0.3% Crash Rate to Zero: Scaling Flutter Cache with Batching & Locking
π Star the repo: Flutter Production Architecture on GitHub
Tags: #Flutter #Security #Keychain #KeyStore #Encryption #Mobile #HardwareSecurity




Top comments (0)