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
Recommended Identity Strategy
| 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