DEV Community

Cover image for The JWT Token Incident: Why Your Flutter App's Cache Isn't Secure (And How to Fix It)
Mahmoud Alatrash
Mahmoud Alatrash

Posted on

The JWT Token Incident: Why Your Flutter App's Cache Isn't Secure (And How to Fix It)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<?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>
Enter fullscreen mode Exit fullscreen mode

Everything was in plain text.

The auditor then demonstrated:

  1. Extracting the JWT token
  2. Using it to authenticate API requests from Postman
  3. 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);
Enter fullscreen mode Exit fullscreen mode

Why explicit? We considered auto-detecting sensitive keys (e.g., if key contains "token" or "password"), but rejected it:

  1. False positives: "password_hint" doesn't need encryption
  2. False negatives: "auth" is vagueβ€”could be sensitive or not
  3. 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);
Enter fullscreen mode Exit fullscreen mode

Behind the scenes:

  1. Flutter plugin calls iOS Keychain API (SecItemAdd)
  2. Keychain API sends request to securityd daemon (IPC via XPC)
  3. securityd communicates with Secure Enclave
  4. Secure Enclave generates encryption key (never leaves the coprocessor)
  5. Data encrypted with AES-256-GCM
  6. Encrypted blob stored in SQLite database at /var/Keychains/keychain-2.db
  7. 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
  ),
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Behind the scenes (API 23+):

  1. Flutter plugin uses EncryptedSharedPreferences
  2. EncryptedSharedPreferences retrieves master key from KeyStore
  3. If master key doesn't exist, KeyStore generates one in TrustZone
  4. Data encrypted with AES-256-GCM using master key
  5. Encrypted data stored in SharedPreferences XML
  6. 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>
Enter fullscreen mode Exit fullscreen mode

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)');
}
Enter fullscreen mode Exit fullscreen mode

The Performance Tax

Secure storage is 50-100x slower than SharedPreferences:

Why so slow?

  1. Cryptographic operations: AES-256 encryption/decryption on every call
  2. Inter-process communication:
    • iOS: XPC calls to securityd daemon (context switch)
    • Android: Binder IPC to KeyStore service
  3. 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
Enter fullscreen mode Exit fullscreen mode

Compare to SharedPreferences:

Your App (Dart) β†’ Flutter Plugin (Native) β†’ File I/O β†’ Response
     ↓                    ↓                      ↓
   1ms                   2ms                   5ms
                                              ────
                                              8ms total
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Protection strategy:

In AndroidManifest.xml:

<application
    android:allowBackup="false"
    android:fullBackupContent="false">
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Physical extraction: Connect device via JTAG, exploit bootloader
  2. Logical extraction: Use vendor debug protocols (iOS: lockdownd, Android: ADB)
  3. Filesystem analysis: Parse SQLite, XML, binary files
  4. 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:

  1. Layer 1: App Sandbox

    • Protection: Prevents other apps from reading data.
    • Vulnerability: Can be bypassed by Root access or physical extraction.
  2. 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.
  3. Layer 3: Hardware-Backed Keys

    • Protection: Keys are isolated in Secure Enclave/TEE (Hardware level).
    • Vulnerability: Extremely difficult to bypass (requires expensive hardware attacks).
  4. 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');
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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)