Introduction
Authentication tokens are the keys to your users' accounts. Losing them to local device compromise or careless storage practices exposes your entire user base to account takeovers and data theft. Store tokens in secure storage: best practices explained gives you a clear, production-tested path to protect these credentials on mobile devices.
We've seen too many production incidents where tokens were stored in SharedPreferences on Android or UserDefaults on iOS—both completely unencrypted. A malicious app with file access, a rooted device, or a forensic examination can extract those tokens in seconds. This article walks through the native secure storage layers available on each platform, how to wrap them in Flutter, and the operational practices that keep tokens safe throughout their lifecycle.
Why secure storage matters for tokens
Tokens represent authenticated identity. A stolen token lets an attacker impersonate the user without knowing their password. Once extracted, a token can be used offline, sold to other attackers, or replayed across your infrastructure. Unlike passwords—which should never be stored—tokens must persist locally so the app can authenticate requests without asking the user to log in constantly.
The gap between convenience and security is real. We want fast app startup and seamless re-authentication. We also need to guarantee that tokens can't be extracted by an attacker with physical or logical access to the device. Secure storage APIs close that gap by using platform-level encryption and key management.
Keychain on iOS and Keystore on Android: the native layers
Each platform provides a dedicated secure storage mechanism built into the operating system.
iOS Keychain
Keychain is Apple's credential storage system. Every iOS device has a Keychain protected by the device passcode and biometric authentication. When you store an item in Keychain, iOS encrypts it with a key derived from the device's unique identifier and the user's authentication credentials. The Keychain item is never accessible to other apps, and even a rooted device cannot extract it without the device passcode.
Keychain stores key-value pairs as items with configurable attributes:
- kSecClassGenericPassword: generic credentials like API tokens
- kSecAttrAccessibleAfterFirstUnlock: available after first device unlock (default for background operation)
- kSecAttrAccessibleWhenUnlocked: only accessible while the device is unlocked
For auth tokens used during active app sessions, you typically use AfterFirstUnlock. For sensitive operations (biometric re-authentication), use WhenUnlocked.
Android Keystore
Android's Keystore is a container for cryptographic keys. You can generate keys in the Keystore, which ensures they never leave the secure enclave (TEE or StrongBox on supported devices). When you encrypt data with a Keystore key, the encrypted blob can be stored anywhere—even SharedPreferences—because only the Keystore can decrypt it.
Keystore keys support hardware backing on devices with a TEE (Trusted Execution Environment). With hardware backing, the decryption operation happens inside the secure enclave, and the plaintext never passes through the main OS kernel. This is a significant security boundary.
You can also configure Keystore keys to require user authentication (unlock pattern, PIN, or biometric). If enabled, the key cannot decrypt data until the user authenticates.
Using flutter_secure_storage to wrap both platforms
Writing platform-specific code for each storage layer is error-prone. The flutter_secure_storage package abstracts both Keychain and Keystore under a single Dart API, handling platform differences automatically.
Setup
Add the dependency:
flutter pub add flutter_secure_storageOn iOS, no additional configuration is needed. On Android, ensure your app's build.gradle targets API level 23 or higher (Keystore was introduced in API 18, but modern practices require 23+).
Storing a token
Here's a practical example. After successful login, store the access token and refresh token:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenManager {
static const _storage = FlutterSecureStorage();
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
Future<void> saveTokens({
required String accessToken,
required String refreshToken,
}) async {
try {
await Future.wait([
_storage.write(key: _accessTokenKey, value: accessToken),
_storage.write(key: _refreshTokenKey, value: refreshToken),
]);
} catch (e) {
// Log and propagate; never silently fail
print('Failed to save tokens: $e');
rethrow;
}
}
Future<String?> getAccessToken() async {
try {
return await _storage.read(key: _accessTokenKey);
} catch (e) {
print('Failed to read access token: $e');
return null;
}
}
Future<String?> getRefreshToken() async {
try {
return await _storage.read(key: _refreshTokenKey);
} catch (e) {
print('Failed to read refresh token: $e');
return null;
}
}
}
The write method encrypts the value before storing it. On iOS, it uses Keychain. On Android, it generates a Keystore key (on first use) and encrypts the data with it.
Clear tokens on logout
Tokens have a defined lifecycle. When the user logs out, or when the refresh token expires and cannot be renewed, delete the tokens immediately. A stale token left in storage is a security liability.
Extend the TokenManager with a logout method:
Future<void> clearTokens() async {
try {
await Future.wait([
_storage.delete(key: _accessTokenKey),
_storage.delete(key: _refreshTokenKey),
]);
} catch (e) {
print('Failed to clear tokens: $e');
rethrow;
}
}
Call this in your logout handler:
Future<void> logout() async {
// 1. Notify the backend (optional; server may have already invalidated the token)
try {
await _apiClient.post('/logout');
} catch (e) {
// Log but don't block token deletion
print('Logout API call failed: $e');
}
// 2. Delete tokens immediately
await _tokenManager.clearTokens();
// 3. Reset app state (clear user data, navigation stack, etc.)
await _userStore.clear();
await _navigationService.reset();
}
Even if the backend logout call fails, the local tokens must be cleared. This prevents the app from attempting to use a stale token on the next request.
Logout on token expiration
If a refresh token expires and the backend rejects the refresh request, treat it as a forced logout. Clear tokens and prompt the user to log in again:
Future<String?> getValidAccessToken() async {
var token = await _tokenManager.getAccessToken();
if (token == null) {
// No token; user is logged out
return null;
}
if (_isTokenExpired(token)) {
// Try to refresh
try {
final newToken = await _refreshAccessToken();
return newToken;
} catch (e) {
// Refresh failed; clear tokens and log out
await _tokenManager.clearTokens();
await _userStore.clear();
rethrow;
}
}
return token;
}
Handle secure storage errors gracefully
Secure storage operations can fail for several reasons: device is locked, biometric authentication was cancelled, hardware is unavailable, or the OS ran out of secure space. Your app must handle these errors without crashing and without silently using expired or missing tokens.
Distinguish error types
The flutter_secure_storage package throws a PlatformException with a code that indicates the failure reason. Common codes on Android include:
- SECURITY_EXCEPTION: Keystore operation failed; may indicate a compromised device or OS bug.
- USER_CANCELLED: Biometric authentication was cancelled by the user (if configured).
On iOS:
- errSecItemNotFound: The item doesn't exist in Keychain.
- errSecUserCanceled: Biometric authentication was cancelled.
Implement robust error handling
Wrap storage operations and classify errors:
enum TokenStorageError {
notFound,
userCancelled,
securityException,
unknown,
}
extension on PlatformException {
TokenStorageError classify() {
if (code == 'errSecItemNotFound' || code == 'not_found') {
return TokenStorageError.notFound;
}
if (code.contains('CANCELLED') || code.contains('userCanceled')) {
return TokenStorageError.userCancelled;
}
if (code == 'SECURITY_EXCEPTION') {
return TokenStorageError.securityException;
}
return TokenStorageError.unknown;
}
}
class TokenManager {
Future<String?> getAccessToken({bool handleErrors = true}) async {
try {
return await _storage.read(key: _accessTokenKey);
} on PlatformException catch (e) {
final errorType = e.classify();
if (!handleErrors) rethrow;
switch (errorType) {
case TokenStorageError.notFound:
// Token doesn't exist; user is logged out
return null;
case TokenStorageError.userCancelled:
// User cancelled biometric auth; retry later
throw Exception('Biometric authentication cancelled');
case TokenStorageError.securityException:
// Serious issue; log and force logout
print('Security exception reading token: $e');
await clearTokens();
throw Exception('Token storage security error; please log in again');
case TokenStorageError.unknown:
// Unexpected error; log and propagate
print('Unknown token storage error: $e');
rethrow;
}
}
}
}
This approach ensures that:
- Missing tokens (notFound) are handled gracefully as a logged-out state.
- User cancellations (userCancelled) are retried on the next request.
- Security exceptions trigger immediate cleanup and forced re-authentication.
- Unknown errors are logged and escalated so your team can investigate.
Common mistakes
- Storing tokens in SharedPreferences or UserDefaults. These are unencrypted. They can be extracted by any app with file access or a rooted device. Always use platform-specific secure storage.
- Not clearing tokens on logout. A lingering token is a vulnerability. If the user shares the device, the next person can impersonate them. Delete tokens immediately.
- Silently catching storage errors. If reading a token fails, your app might continue with a null token and appear logged out—or worse, use a cached/stale token. Log errors and force explicit re-authentication.
- Storing long-lived tokens. Use short-lived access tokens (minutes to hours) and short or medium-lived refresh tokens (hours to days). Reduce the window of exposure if a token is compromised.
- Forgetting to handle device lock after app launch. If a user locks the device while your app is in the background, Keychain with WhenUnlocked becomes inaccessible. The app must handle this gracefully and re-prompt authentication.
- Not testing on both platforms. iOS Keychain and Android Keystore have different semantics and error codes. Test logout, token refresh, and error scenarios on actual devices or emulators for both platforms.
Key takeaways
- Never store tokens in SharedPreferences or UserDefaults. Use Keychain on iOS and Keystore on Android; both are encrypted by the OS.
- Use flutter_secure_storage to abstract platform differences. It handles encryption, key management, and platform-specific APIs in a single Dart interface.
- Clear tokens immediately on logout or refresh failure. A stale token is a security liability. Deleting it prevents replay attacks and forces re-authentication.
- Handle secure storage errors explicitly. Distinguish between missing tokens, user cancellations, and security exceptions. Log errors and provide clear feedback to the user.
- Use short-lived access tokens and refreshable tokens. Limit the window of exposure if a token is compromised. Refresh tokens should also be revocable by the backend.
- Test on both platforms and devices. Keychain and Keystore have different error codes and state management. Use real devices or platform-specific emulators to validate logout, refresh, and error handling.
- Consider biometric authentication for sensitive operations. On both iOS and Android, you can configure Keystore/Keychain to require Face ID or fingerprint before decrypting a token. This adds an extra security layer for high-value operations.






Comments
Loading comments…