How Flutter Stores Tokens Securely

How Flutter Stores Tokens Securely

A Deep Dive into Keychain, Keystore & Token Management

The Token Security Problem Every Flutter Developer Faces

When the Flutter app went from MVP to production. We've got user authentication working, push notifications firing, and analytics tracking user behavior. Everything seems fine—until we ask ourselves a critical question:

"Where are we actually storing these tokens, and are they secure?"

If we're storing authentication tokens, FCM tokens, or analytics IDs in SharedPreferences, we have a serious security vulnerability. If we're unsure how iOS Keychain differs from Android Keystore, we might be making dangerous assumptions about cross-platform behavior.

At Hoomanely, our pet care app handles FCM tokens for real-time pet notifications and analytics IDs across PostHog, AppsFlyer, and Customer.io. We learned the hard way that token management isn't just a backend problem—it's a critical mobile security concern.

This guide walks through exactly how secure storage works on both platforms, and what happens under the hood with flutter_secure_storage, and how to build a bulletproof token management system.


Why Token Storage Is a Security-Critical Decision

Tokens aren't just random strings—they're the keys to our users' kingdom:

  • Auth tokens = immediate login access to the account
  • FCM tokens = the ability to trigger notifications to any device
  • Analytics IDs = cross-device identity and behavioral tracking

A leaked token means:

  • ✗ Account impersonation and unauthorized access
  • ✗ Spam notifications sent to users
  • ✗ Corrupted analytics and broken user tracking
  • ✗ Loss of user trust and potential regulatory violations

Bottom line: If we're not using platform-level secure storage, we're putting our users at risk.


How flutter_secure_storage Actually Works

Most Flutter apps use the flutter_secure_storage package. But what happens when you call secureStorage.write()?

The magic happens in the platform layer:

Flutter (Dart) → Platform Channel → Native Code → Secure Storage System

Here's what actually gets invoked on each platform:

iOS: Keychain

iOS Keychain is Apple's battle-tested secure storage system, and it's exceptional:

✓ Hardware-backed encryption using AES keys from the Secure Enclave chip
✓ Survives app reinstalls (when configured correctly)
✓ Biometric protection can lock specific entries
✓ App Group support for sharing tokens with extensions
✓ Extremely stable across iOS versions

Threat protection:

  • Impossible to brute-force (Secure Enclave is purpose-built cryptographic hardware)
  • Protected even if app binary is decompiled
  • Can require Face ID/Touch ID for access

Android: Keystore + EncryptedSharedPreferences

Android's story is more complex because it spans multiple security layers:

✓Android Keystore generates hardware-backed encryption keys
✓ EncryptedSharedPreferences uses those keys to encrypt actual data
✓ TEE (Trusted Execution Environment) or StrongBox provides hardware isolation
✗ Can lose data on major OS updates or OEM-specific resets
✗ Behavior varies significantly across manufacturers (Samsung vs Xiaomi vs Google)

Threat protection:

  • Keys stored in a hardware security module (when available)
  • Per-user, per-device encryption
  • Less consistent than iOS—some devices have quirks

The Platform Mapping: What Happens on Each Call

Flutter Method iOS (Keychain) Android (Keystore)
write(key, value) Creates encrypted Keychain item with accessibility rules Generates AES key in Keystore, encrypts value, stores in EncryptedSharedPreferences
read(key) Retrieves and decrypts Keychain item Retrieves encrypted value, uses Keystore key to decrypt
delete(key) Removes Keychain entry Deletes encrypted entry and optionally removes key
deleteAll() Clears all items in Keychain service/group Clears all encrypted entries (may keep master key)

This explains why our security is only as strong as the platform implementation beneath it.


Storing Authentication Tokens: The Right Way

Auth tokens come in multiple flavors, and each has different security requirements:

Token Type Storage Location Reasoning
Access Token In-memory or Secure Storage Short-lived (5–15 min); can be refreshed safely
Refresh Token Secure Storage ONLY Long-lived (30–90 days); highest security priority
ID Token Secure Storage Contains user identity claims
Session Cookie Secure Storage Equivalent to refresh token sensitivity

Why NOT SharedPreferences?

Plain SharedPreferences is just an XML file (Android) or plist file (iOS) stored on disk in an unencrypted app directory:

  • ✗ Readable by rooted/jailbroken devices
  • ✗ Accessible to malware with storage permissions
  • ✗ Extracted from device backups
  • ✗ Visible if the disk is mounted externally

Never store sensitive tokens in SharedPreferences.

Practical Implementation

class TokenRepository {
  final FlutterSecureStorage _secureStorage;

  TokenRepository(this._secureStorage);

  Future<String?> getAccessToken() async {
    return await _secureStorage.read(key: 'access_token');
  }

  Future<void> saveAccessToken(String token) async {
    await _secureStorage.write(key: 'access_token', value: token);
  }

  Future<String?> getRefreshToken() async {
    return await _secureStorage.read(key: 'refresh_token');
  }

  Future<void> saveRefreshToken(String token) async {
    await _secureStorage.write(key: 'refresh_token', value: token);
  }

  Future<void> clearTokens() async {
    await _secureStorage.deleteAll();
  }
}

FCM Tokens: The Overlooked Security Risk

Firebase Cloud Messaging tokens identify devices for push notifications. Many developers don't realize these need secure storage too.

Why FCM Tokens Are Sensitive

  • Can be used to send unauthorized notifications to users
  • Tied directly to analytics and device identity
  • Persist across app sessions and should survive reinstalls
  • Can be weaponized for spam or social engineering attacks

At Hoomanely, FCM tokens are mapped to:

  • User account IDs
  • Analytics distinct IDs

A leaked FCM token could mean fake weight alerts, incorrect feeding notifications, or analytics corruption.

Storage Best Practice

Future<void> saveFCMToken(String token) async {
  await _secureStorage.write(key: 'fcm_token', value: token);
}

Future<String?> getFCMToken() async {
  return await _secureStorage.read(key: 'fcm_token');
}

Token Rotation: The Critical Lifecycle

Tokens don't last forever. They rotate for multiple reasons:

  • User logs in/out
  • App reinstalls or data clears
  • OS-level storage wipes (Android)
  • Backend invalidates token (security event)
  • FCM decides the token is stale

Analytics Identity Management: The Hidden Complexity

Analytics is where many Flutter apps fail silently. You need:

  • Stable, distinct ID for long-term user tracking
  • Correct user ID after login/logout
  • Identity merging when transitioning from anonymous → authenticated
  • Protected storage to prevent ID spoofing
Scenario Distinct ID Storage
First app open Anonymous UUID Secure Storage
After login Backend user ID Secure Storage
After logout New anonymous UUID Secure Storage

At Hoomanely, we manage:

  • PostHog distinct ID for product analytics
  • AppsFlyer UID for attribution tracking
  • Customer.io device ID for messaging
  • Internal device ID for Bluetooth pairing

All must stay synchronized across logins, logouts, reinstalls, and device changes.

Implementation Pattern

class AnalyticsIdentityManager {
  final FlutterSecureStorage _storage;

  Future<String> getDistinctId() async {
    var id = await _storage.read(key: 'analytics_distinct_id');
    
    if (id == null) {
      id = Uuid().v4();
      await _storage.write(key: 'analytics_distinct_id', value: id);
    }
    
    return id;
  }

  Future<void> identifyUser(String userId) async {
    // Store user ID and associate with analytics
    await _storage.write(key: 'analytics_user_id', value: userId);
    
    // Tell analytics services to merge identities
    await _postHog.identify(userId);
    await _appsFlyer.setCustomerUserId(userId);
  }

  Future<void> resetOnLogout() async {
    await _storage.delete(key: 'analytics_user_id');
    // Keep or rotate distinct ID based on your privacy policy
  }
}

Architectural Pattern: Token Repository with Dependency Injection

To prevent token misuse and ensure consistency, centralize token access:

class TokenRepository {
  final FlutterSecureStorage _secureStorage;

  TokenRepository(this._secureStorage);

  // Auth tokens
  Future<String?> get accessToken async => 
    _secureStorage.read(key: 'access_token');
    
  Future<String?> get refreshToken async => 
    _secureStorage.read(key: 'refresh_token');

  // FCM token
  Future<String?> get fcmToken async => 
    _secureStorage.read(key: 'fcm_token');

  // Analytics IDs
  Future<String?> get analyticsDistinctId async => 
    _secureStorage.read(key: 'analytics_distinct_id');

  Future<void> saveTokens({
    String? accessToken,
    String? refreshToken,
    String? fcmToken,
  }) async {
    if (accessToken != null) {
      await _secureStorage.write(key: 'access_token', value: accessToken);
    }
    if (refreshToken != null) {
      await _secureStorage.write(key: 'refresh_token', value: refreshToken);
    }
    if (fcmToken != null) {
      await _secureStorage.write(key: 'fcm_token', value: fcmToken);
    }
  }

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

Inject this into:

  • Network/API clients
  • Analytics modules
  • Notification handlers
  • Authentication providers

Complete Token Lifecycle Diagram

┌──────────────────┐
│  App Launches    │
└────────┬─────────┘
         │
         ▼
┌────────────────────────────┐
│ Read tokens from           │
│ Secure Storage             │
└────────┬───────────────────┘
         │
         ▼
┌────────────────────────────┐
│ Refresh access token?      │
└────────┬───────────────────┘
         │
    ┌────┴────┐
    │         │
   Yes       No
    │         │
    ▼         ▼
┌─────────┐  ┌──────────┐
│ Success │  │  Logout  │
└────┬────┘  └──────────┘
     │
     ▼
┌──────────────────────────┐
│ Store new access token   │
└────────┬─────────────────┘
         │
         ▼
┌──────────────────────────┐
│ Verify/update FCM token  │
└────────┬─────────────────┘
         │
         ▼
┌──────────────────────────┐
│ Sync analytics identity  │
└──────────────────────────┘

Real-World Context: Why This Matters at Hoomanely

Token handling isn't theoretical—it directly impacts user experience in our pet care app:

Push notifications: It depends, on valid FCM tokens. Stale tokens = missed notifications = unhappy pet owners.

Analytics accuracy: Product insights rely on stable, distinct IDs. Incorrect token handling leads to inflated user counts and broken funnel analysis.

Security: Leaked tokens could expose owner information.


Key Takeaways

  • Always use Secure Storage for tokens—never SharedPreferences
  • iOS Keychain is rock-solid; Android Keystore varies by OEM
  • FCM tokens need secure storage to prevent notification abuse
  • Analytics IDs must be stored, merged, and rotated correctly
  • Use Dependency Injection for consistent token access
  • Token rotation must include auth + FCM + analytics identity
  • Refresh tokens are the highest-priority security assets
  • Test token flows across logout, reinstall, and OS updates
  • Never assume cross-platform consistency without testingthe

Read more