Secure Storage in Flutter: When SharedPreferences Isn’t Enough
When to use SharedPreferences vs secure storage in Flutter—threats, code examples, migration steps, and best practices for tokens and PII.
Image used for representation purposes only.
Overview
SharedPreferences is the go-to key–value store for many Flutter apps. It’s perfect for lightweight, non-sensitive settings such as theme, onboarding flags, or simple counters. But it is not designed for secrets. If you place access tokens, refresh tokens, API keys, or personally identifiable information (PII) in SharedPreferences, you risk exposing that data to attackers with device access, backups, or debugging tools.
This article explains why SharedPreferences is not secure by default, what “secure storage” means on mobile, and how to implement a robust, cross‑platform solution in Flutter. You’ll see code snippets, migration steps, and best practices that balance security with developer ergonomics.
Why SharedPreferences Is Not Secure
Under the hood, SharedPreferences stores data in plain text files (platform-specific formats) within your app’s sandbox. While other apps cannot normally read these files, several scenarios make them unsafe for secrets:
- Physical or root/jailbreak access: On compromised devices, sandbox files can be browsed and copied easily.
- Debug/release extraction: Power users can extract app data from backups or via developer tools.
- Web and desktop variants: On Flutter web, “shared preferences” map to browser storage (e.g., localStorage), which is trivially readable by injected scripts and extensions. Desktop implementations can be similarly exposed.
Conclusion: Use SharedPreferences only for non-sensitive preferences.
What Counts as “Sensitive” Data?
Treat the following as sensitive:
- Authentication material: access tokens, refresh tokens, session IDs, device secrets
- Payment-related hints: partial card data, billing info, PCI-sensitive tokens
- PII: email, phone, address, DOB, national IDs
- API keys or private configuration that would grant unintended access
When in doubt, treat it as sensitive.
Secure Storage on Mobile: The Mental Model
Secure storage leverages platform keychains and keystores to encrypt data at rest and bind decryption to device security:
- iOS/iPadOS/macOS: Keychain. Items can be scoped to when the device is unlocked, first unlock after boot, or restricted to this device only. Data may be hardware-backed (Secure Enclave) on supported devices.
- Android: The Android Keystore protects a master key, which encrypts data at rest (often via EncryptedSharedPreferences under the hood). Keys may be hardware-backed (TEE/StrongBox) depending on OEM support and lock screen configuration.
- Web: There is no equivalent secure enclave available to Flutter apps. Assume client-side persistence is not secure for secrets.
In Flutter, the most common abstraction is the flutter_secure_storage package, which uses the platform keychain/keystore automatically.
When to Use SharedPreferences vs Secure Storage
Use SharedPreferences for:
- UI preferences (theme, language)
- Non-sensitive feature flags
- Caches that can be recomputed or safely discarded
Use secure storage for:
- Tokens (access/refresh), session secrets
- User identifiers and PII
- Any credential-like value that would harm users or systems if leaked
Baseline Implementation
Add dependencies in pubspec.yaml (omit exact versions here to avoid drift):
dependencies:
shared_preferences: any
flutter_secure_storage: any
local_auth: any # optional, for biometric gating
Writing Non-Sensitive Data with SharedPreferences
import 'package:shared_preferences/shared_preferences.dart';
class SettingsStore {
Future<void> setTheme(String theme) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('theme', theme); // safe: non-sensitive
}
Future<String?> getTheme() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('theme');
}
}
Storing Secrets with flutter_secure_storage
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecretStore {
// The default constructor picks sensible, secure defaults on each platform.
static const _storage = FlutterSecureStorage();
Future<void> saveToken(String token) async {
await _storage.write(key: 'auth_token', value: token);
}
Future<String?> readToken() async {
return _storage.read(key: 'auth_token');
}
Future<void> clearToken() async {
await _storage.delete(key: 'auth_token');
}
Future<void> clearAll() async {
await _storage.deleteAll();
}
}
Notes:
- On iOS/macOS, the plugin stores data in the Keychain. Accessibility (when an item is readable) can be tuned; default is generally safe for most apps.
- On Android, the plugin uses the Keystore to protect an encryption key and persists your values encrypted at rest.
- On the web, do not treat this as secure. Prefer memory-only tokens or server-managed sessions.
Adding Biometric Gating (Optional)
Even though secure storage encrypts data at rest, you may want an extra interactive check (e.g., Face ID/Touch ID, fingerprint) before reading a secret.
import 'package:local_auth/local_auth.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class BiometricSecretStore {
static const _storage = FlutterSecureStorage();
final _auth = LocalAuthentication();
Future<bool> _authenticate() async {
final canAuth = await _auth.canCheckBiometrics || await _auth.isDeviceSupported();
if (!canAuth) return true; // fallback: don’t block if unavailable
return _auth.authenticate(
localizedReason: 'Unlock secure data',
options: const AuthenticationOptions(biometricOnly: false),
);
}
Future<String?> readTokenProtected() async {
final ok = await _authenticate();
if (!ok) return null;
return _storage.read(key: 'auth_token');
}
}
Guidance:
- Biometric prompts protect against shoulder surfing or opportunistic access while the app is open.
- Handle platform exceptions: keys can be invalidated if the user changes the device lock method or biometrics.
Platform-Specific Options (What to Tune)
- iOS/macOS Keychain accessibility: Choose when items are readable (e.g., only when unlocked, first unlock after boot, device-only vs iCloud-synced). Many apps prefer “after first unlock, this device only” for tokens to balance usability and safety.
- Android key alias and storage: Defaults are safe. If you face “key permanently invalidated” errors after biometric/lock changes, catch the error, wipe secure items, and re-create them on next sign-in.
- Backups and restores: Keychain/Keystore material may not survive every restore path. Design your auth flow to request a fresh login if secrets become unreadable.
Consult the flutter_secure_storage docs for the exact option names on your package version.
Migrating from SharedPreferences to Secure Storage
If you previously saved secrets in SharedPreferences, migrate them seamlessly:
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecretMigration {
static const _storage = FlutterSecureStorage();
Future<void> migrateIfNeeded() async {
final prefs = await SharedPreferences.getInstance();
// Example keys that should be secure
const keysToMigrate = <String>['auth_token', 'refresh_token', 'user_email'];
for (final k in keysToMigrate) {
final v = prefs.getString(k);
if (v != null) {
await _storage.write(key: k, value: v);
await prefs.remove(k);
}
}
}
}
Tips:
- Run migration once at app startup before reading any token.
- Audit your keys; ensure no leftover secrets remain in SharedPreferences.
- Consider forcing a fresh login if migration fails or keys are unreadable.
A Clean Abstraction: Preference vs Secret Stores
Separate concerns with two services. This keeps your codebase honest about what belongs where.
class AppStorage {
final SettingsStore prefs;
final SecretStore secrets;
AppStorage({required this.prefs, required this.secrets});
}
Use the “prefs” store for UI settings and the “secrets” store for credentials. Enforce boundaries in code review.
Performance Considerations
- Secure storage is slower than SharedPreferences due to encryption and keychain/keystore calls. For tokens, this is acceptable because reads are infrequent.
- Avoid tight loops with many secure reads/writes; batch where possible.
- Cache sensitive values in memory during a session and clear them on logout or when the app backgrounds, depending on your policy.
Web and Desktop Caveats
- Web: Do not persist long-lived secrets in the browser. Prefer short-lived access tokens, server-managed sessions (HTTP-only cookies), or in-memory storage with silent refresh. Treat any persistent web storage as non-secure.
- Desktop: macOS maps to Keychain via the same plugin. Windows/Linux implementations vary; plan for best-effort security and test your threat model on those platforms.
Threat Modeling: Good Enough for Your App
- Casual attacker: Device thieves without the passcode are typically blocked by hardware-backed keys; secure storage provides strong protection.
- Advanced attacker: Rooted/jailbroken devices or malware can still exfiltrate secrets at runtime. Mitigations include device integrity checks, server-side risk scoring, rate limiting, and short-lived tokens.
- Insider or backend compromise: Never ship admin keys in clients. Keep high-privilege credentials server-side only.
Security is layered. Secure storage is necessary but not sufficient.
Best Practices Checklist
- Do not store secrets in SharedPreferences.
- Use flutter_secure_storage (or equivalent) for tokens and PII.
- Add optional biometric gating for high-value operations.
- Handle key invalidation: catch errors, wipe, and re-authenticate.
- Keep tokens short-lived; rotate refresh tokens server-side.
- Cache secrets in memory only as needed; clear on logout/background if policy requires.
- Avoid storing encryption keys in code or SharedPreferences. Let the OS keystore manage them.
- Log carefully: never print secrets to console/logs/analytics.
Testing and Troubleshooting
- Simulate key invalidation: change device lock method, remove biometrics, or toggle screen lock to ensure your app handles re-login properly.
- Device matrix: Test across iOS versions, Android OEMs, emulators, and physical devices; hardware support varies.
- Cold boot/first unlock: Ensure your app can read what it needs after reboot without hanging on locked devices.
- Upgrade paths: Write migration tests from older app versions that used SharedPreferences for secrets.
Putting It All Together
- Preferences (non-sensitive) → SharedPreferences
- Secrets (tokens, PII) → flutter_secure_storage
- Optional interactive step → local_auth biometrics
- On web → avoid persisting secrets; prefer cookies or memory
- On upgrade → migrate and delete legacy plaintext keys
Adopting these patterns reduces your risk surface dramatically with minimal code changes. Treat secure storage as your default for anything that could hurt users or systems if leaked, and use SharedPreferences only for what its name implies: preferences.
Related Posts
Migrating to Dart 3 Null Safety in Flutter: A Practical Guide
Step-by-step guide to migrate a Flutter codebase to Dart 3’s sound null safety with commands, patterns, pitfalls, and checklists.
Flutter Cupertino Widgets: An iOS-Style UI Guide with Practical Examples
A practical guide to Flutter’s Cupertino iOS-style widgets: app shells, navigation, lists, forms, pickers, theming, and adaptive patterns with code.
Flutter Push Notifications with Firebase Cloud Messaging (FCM): A Complete Setup Guide
Step-by-step guide to set up FCM push notifications in Flutter for Android and iOS, with code, permissions, background handling, and testing tips.