Part 1 of the Flutter Security Beyond the Basics series.
The token sitting in a file anyone can read
You have built a Flutter app with authentication. The user logs in, your backend returns a JWT, and you need to persist it so the user stays logged in across app restarts. You reach for SharedPreferences because it is the obvious choice — every tutorial uses it, it is simple, and it works.
final prefs = await SharedPreferences.getInstance();
await prefs.setString('auth_token', token);Two lines. Done. Ship it.
Except what you have just done is write that token — the single credential that grants access to the user's account — into a plaintext file on the device's filesystem. Not encrypted. Not obfuscated. Plaintext.
On a rooted Android phone, any application with root access can open that file and read your user's token. On a non-rooted phone, someone with physical access who decides to root it (which takes minutes on many devices) gets everything. On iOS, a jailbroken device gives the same result.
This is not a theoretical risk. It is the starting point for most mobile app penetration tests, and it is one of the first things OWASP's mobile testing guide tells auditors to check.
Let us look at exactly what happens on disk, why it matters, and what to do instead.
What SharedPreferences actually writes to disk
Android
On Android, SharedPreferences writes an XML file to the app's internal storage directory:
/data/data/com.yourapp.package/shared_prefs/FlutterSharedPreferences.xmlOpen that file, and you will see something like this:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="flutter.auth_token">eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNjE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c</string>
<string name="flutter.refresh_token">dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4gdGhhdCBzaG91bGQgbm90IGJlIGhlcmU</string>
<string name="flutter.user_theme">dark</string>
</map>That JWT is sitting right there. The refresh token too. Anyone who can read this file can copy those tokens, use them from another device, and impersonate the user until the tokens expire — or indefinitely if the refresh token has no expiry.
Flutter prefixes all SharedPreferences keys with flutter., which makes them even easier to find. An attacker does not need to guess the key name; they just search for flutter. in the XML.
iOS
On iOS, SharedPreferences maps to NSUserDefaults, which writes a property list file to:
<app-sandbox>/Library/Preferences/com.yourapp.package.plistThe content is similar — a structured file with your keys and values in the clear. On a jailbroken device, or when extracting a device backup that is not encrypted, this file is trivially accessible.
The root of the problem
SharedPreferences was never designed for sensitive data. On Android, it is literally a convenience wrapper around an XML file. On iOS, NSUserDefaults is documented by Apple as being for "user preferences and small amounts of data" — not secrets. Neither platform encrypts the data at rest. Neither ties the data to any hardware security boundary.
The app sandbox does provide some protection on a non-rooted, non-jailbroken device. Other apps cannot normally access your app's internal storage. But the sandbox is a software boundary, and software boundaries can be bypassed. Rooting, jailbreaking, ADB backup extraction, or even a malware app that exploits a privilege escalation vulnerability — all of these break the sandbox assumption.
What Keychain and Android Keystore do differently
Both iOS and Android provide a dedicated, hardware-backed mechanism for storing secrets. They work on fundamentally different principles from SharedPreferences.
iOS Keychain
The iOS Keychain is an encrypted database managed by the operating system. When you store a value in the Keychain:
- The OS encrypts the data using a key derived from the device's hardware and (optionally) the user's passcode.
- The encrypted data is stored in a system-managed SQLite database, not in your app's sandbox.
- Access is controlled by the app's entitlements — only your app (identified by its code signing identity) can retrieve the values it stored.
The encryption key is managed by the Secure Enclave, a dedicated hardware chip. The key material never leaves that chip. The OS itself cannot extract it. When your app asks to read a Keychain item, the Secure Enclave performs the decryption internally and returns only the result. Even if someone dumps the entire filesystem, the encrypted Keychain data is useless without the hardware key.
Android Keystore
Android Keystore (available since API 18, hardware-backed since API 23) provides similar guarantees:
- Cryptographic keys are generated and stored inside a hardware security module (HSM) or Trusted Execution Environment (TEE).
- The key material never leaves the secure hardware. Encryption and decryption operations happen inside the TEE.
- Keys are bound to your app's UID — other apps cannot use them.
When you use EncryptedSharedPreferences (the AndroidX Security library), it creates an AES-256 key in the Android Keystore and uses it to encrypt both the keys and values before writing them to a SharedPreferences file. The file on disk contains only ciphertext.
In plain terms
Think of SharedPreferences as writing your house key on a sticky note and putting it under the doormat. The Keychain and Keystore are more like a safe that is welded to the foundation of the house, where the combination exists only inside a chip that destroys itself if you try to open it.
Neither system is theoretically unbreakable. But the effort required to extract a Keychain or Keystore secret is orders of magnitude higher than reading a plaintext XML file. It moves the attack from "run a script" to "acquire specialised hardware and exploit chip-level vulnerabilities."
flutter_secure_storage — the Flutter wrapper
The flutter_secure_storage package provides a unified Dart API that uses Keychain on iOS and EncryptedSharedPreferences (backed by Android Keystore) on Android.
Installation
dependencies:
flutter_secure_storage: ^9.2.4On Android, you need minSdkVersion 23 or higher in your android/app/build.gradle to get hardware-backed key storage. If you are still targeting API levels below 23, you should stop — those Android versions are past end-of-life and represent a small fraction of active devices.
Creating the storage instance with proper options
Do not just instantiate FlutterSecureStorage() with no arguments. Configure it explicitly:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);The encryptedSharedPreferences: true option on Android tells the plugin to use AndroidX EncryptedSharedPreferences rather than the older AES-CBC implementation. This is the recommended approach for Android 6.0 and above.
iOS KeychainAccessibility levels
The accessibility parameter controls when the Keychain item can be read. This matters because it determines whether the data is available when the device is locked.
| Level | When accessible | Use case |
|---|---|---|
| passcode | Only while the device is unlocked and a passcode is set | Highest security. Good for encryption keys. |
| unlocked | Only while the device is unlocked | Good for tokens accessed during active use. |
| unlocked_this_device | Same as unlocked, but not included in backups | Tokens that should not transfer to a new device. |
| first_unlock | After the user unlocks the device once after reboot | Good for background refresh tasks. Most common choice. |
| first_unlock_this_device | Same as first_unlock, not included in backups | Auth tokens — accessible for background work, do not migrate to new devices. |
| always | Always, even when device is locked | Almost never appropriate. Avoid this. |
| always_this_device | Always, not included in backups | Rarely appropriate. |
For auth tokens, first_unlock_this_device is usually the right choice. It lets your app read the token for background API calls (push notification handlers, background fetch) while preventing the token from being included in iCloud or iTunes backups. The _this_device suffix means if the user restores a backup to a new phone, the token will not be there — they will need to log in again, which is the correct behaviour.
Using always is almost never what you want. It means the Keychain item is readable even when the device is locked, which weakens the protection significantly.
CRUD operations
// Write
await secureStorage.write(key: 'access_token', value: accessToken);
await secureStorage.write(key: 'refresh_token', value: refreshToken);
// Read
final accessToken = await secureStorage.read(key: 'access_token');
// Returns null if not found
// Delete a single item
await secureStorage.delete(key: 'access_token');
// Delete all items stored by your app
await secureStorage.deleteAll();
// Check if a key exists
final hasToken = await secureStorage.containsKey(key: 'access_token');
// Read all key-value pairs
final allValues = await secureStorage.readAll();All operations are asynchronous because they involve system calls to the Keychain or Keystore. They are also slower than SharedPreferences — typically a few milliseconds per operation rather than sub-millisecond. This is a deliberate tradeoff: the encryption and decryption take time.
A complete SecureTokenStorage class
Here is a self-contained class you can drop into a project:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureTokenStorage {
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
final FlutterSecureStorage _storage;
SecureTokenStorage()
: _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
Future<void> saveTokens({
required String accessToken,
required String refreshToken,
}) async {
await Future.wait([
_storage.write(key: _accessTokenKey, value: accessToken),
_storage.write(key: _refreshTokenKey, value: refreshToken),
]);
}
Future<String?> getAccessToken() {
return _storage.read(key: _accessTokenKey);
}
Future<String?> getRefreshToken() {
return _storage.read(key: _refreshTokenKey);
}
Future<bool> hasTokens() async {
final token = await _storage.read(key: _accessTokenKey);
return token != null && token.isNotEmpty;
}
Future<void> clearTokens() async {
await Future.wait([
_storage.delete(key: _accessTokenKey),
_storage.delete(key: _refreshTokenKey),
]);
}
}A few things to note:
- The
FlutterSecureStorageinstance isconst— the plugin supports this and it avoids unnecessary allocations. saveTokensandclearTokensuseFuture.waitto run both operations in parallel. There is no reason to wait for one to finish before starting the other.- The class does not expose the storage instance. Other parts of your app should not be writing arbitrary keys into the secure storage.
What belongs in secure storage vs what does not
Secure storage is not a general-purpose database. It is a small, encrypted key-value store designed for secrets.
Store in secure storage:
- Access tokens
- Refresh tokens
- Session IDs
- API keys that are user-specific
- Encryption keys for local data
- Biometric-related credentials
Do not store in secure storage:
- User theme preferences
- Onboarding completion flags
- Cached API responses
- Entire user profile objects
- Feature flags
- Any data larger than a few kilobytes
The Keychain and Keystore have practical size limits. iOS Keychain items should ideally stay under a few kilobytes. Android EncryptedSharedPreferences can handle more, but performance degrades with size. More importantly, every read and write involves a cryptographic operation. If you are reading a value on every frame or multiple times per second, secure storage is the wrong tool.
If you need to store a large amount of sensitive data locally — say, encrypted messages or medical records — use secure storage to hold the encryption key, and encrypt the data yourself into a local database like SQLite.
Migrating from SharedPreferences
If your app is already in production with tokens in SharedPreferences, you need a migration path. You cannot just switch the storage layer and leave existing users logged out — or worse, leave their old tokens sitting in the plaintext file.
Here is a migration pattern:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class TokenStorageMigration {
static const _migrationKey = 'token_migration_complete';
final FlutterSecureStorage _secureStorage;
TokenStorageMigration()
: _secureStorage = const FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock_this_device,
),
);
Future<void> migrateIfNeeded() async {
final prefs = await SharedPreferences.getInstance();
// Check if migration already happened
final alreadyMigrated = prefs.getBool(_migrationKey) ?? false;
if (alreadyMigrated) return;
// Read tokens from the old location
final accessToken = prefs.getString('auth_token');
final refreshToken = prefs.getString('refresh_token');
// Write to secure storage if they exist
if (accessToken != null && accessToken.isNotEmpty) {
await _secureStorage.write(key: 'access_token', value: accessToken);
}
if (refreshToken != null && refreshToken.isNotEmpty) {
await _secureStorage.write(key: 'refresh_token', value: refreshToken);
}
// Remove tokens from SharedPreferences
await prefs.remove('auth_token');
await prefs.remove('refresh_token');
// Mark migration as complete
await prefs.setBool(_migrationKey, true);
}
}Call migrateIfNeeded() early in your app's startup, before any authentication checks. The migration flag itself can stay in SharedPreferences — it is not sensitive, and it needs to survive across app updates.
The critical step is the removal from SharedPreferences after writing to secure storage. If you skip this, the plaintext tokens remain on disk indefinitely. The migration only helps if you clean up after yourself.
Common mistakes
Using KeychainAccessibility.always
This is the weakest accessibility level. It means the Keychain item can be read even when the device is locked, which defeats much of the purpose of using the Keychain in the first place. Unless you have a very specific reason (a background process that must run before the user ever unlocks the device after a reboot — which is rare), use first_unlock_this_device or unlocked_this_device instead.
Not clearing secure storage on logout
When a user taps "Log out," you need to delete their tokens from secure storage. This sounds obvious, but it is easy to forget when the logout flow involves navigation, state clearing, and API calls.
Future<void> logout() async {
// Clear tokens FIRST, before anything else
await secureTokenStorage.clearTokens();
// Then call the logout endpoint (best effort — don't block on it)
try {
await authService.logout();
} catch (_) {
// Server-side logout failed, but local tokens are already gone
}
// Navigate to login screen
navigator.pushReplacementNamed('/login');
}Clear locally first, then notify the server. If the server call fails, the user is still logged out on this device. If you do it the other way around and the server call succeeds but the local deletion fails (or the app crashes mid-flow), the tokens remain on the device.
Storing entire user objects
Sometimes developers serialise the whole user profile into secure storage:
// Don't do this
await secureStorage.write(
key: 'user',
value: jsonEncode(user.toJson()),
);The user object typically contains the name, email, avatar URL, settings, and other data that is not secret. Putting all of it in secure storage wastes limited Keychain/Keystore space and makes every read slower. Store only the tokens. The user profile can go in SharedPreferences, a local database, or an in-memory cache that is refreshed on app start.
Ignoring platform-specific error handling
Secure storage operations can fail. The Keystore might be unavailable after a factory reset on some Samsung devices. The Keychain might throw an error if the app is running in certain background modes. Always handle errors:
try {
final token = await secureStorage.read(key: 'access_token');
if (token == null) {
// No token — user needs to log in
return;
}
// Use token
} on PlatformException catch (e) {
// Secure storage failed — log the error, treat as logged out
debugPrint('Secure storage error: ${e.message}');
// Optionally prompt re-login
}A PlatformException from secure storage should generally be treated as "no valid token available." Prompt the user to log in again rather than crashing.
What is next
This article covered the storage layer — where your secrets sit at rest. But storage is only one part of the picture. In the next post in this series, we will look at what happens to sensitive data in transit within your app: how to prevent tokens from leaking into logs, how to handle them in memory, and what to do about the system clipboard.
Secure storage gives you a strong foundation. What matters next is making sure the rest of your app does not undermine it.