During a pre-release security review of our field-service app, the reviewer pulled the app's data directory off a rooted test phone and opened FlutterSharedPreferences.xml. Our OAuth refresh token was right there in plain text, next to the dark-mode flag. Nobody had made a deliberate decision to put it there; an early prototype saved it with the first storage API someone found, and it stuck.

That review made us write down a simple rule for every piece of data the app persists: decide its bucket first, then pick the package. This post explains the buckets, what each popular package really does on Android and iOS as of mid-2026, and the platform behaviours that bite after you ship.

Three buckets

Bucket Examples Size Sensitivity Where it goes
Preferences Theme, locale, onboarding seen, last tab Tiny Low shared_preferences
Structured app data Cached lists, drafts, offline records KB to tens of MB Low to medium Hive (hive_ce), SQLite (sqflite, drift)
Secrets Refresh tokens, API keys issued per user, encryption keys Tiny High flutter_secure_storage

The common failure is mixing buckets: secrets in preferences because the API is convenient, or large data in secure storage because it "feels safer". Secure storage is slow and size-constrained by design, and preferences are not encrypted at all.

shared_preferences: small, plain, synchronous-feeling

shared_preferences wraps NSUserDefaults on iOS and macOS, and on Android either the classic SharedPreferences XML file or Jetpack DataStore. On the web it uses localStorage. None of these encrypt data. On a rooted or jailbroken device, or from an unencrypted device backup, the values are readable.

Since version 2.3.0 the package offers two newer APIs alongside the original SharedPreferences class, which the maintainers have said will be deprecated:

  • SharedPreferencesAsync: every read and write goes to the platform store, so it always reflects the latest value, including writes from native code or another isolate.
  • SharedPreferencesWithCache: keeps an in-memory cache of an allow-listed set of keys for synchronous reads, and writes through to the platform.
import 'package:shared_preferences/shared_preferences.dart';

final prefs = SharedPreferencesAsync();

Future<void> saveTheme(String mode) => prefs.setString('theme_mode', mode);
Future<String> loadTheme() async => await prefs.getString('theme_mode') ?? 'system';

// synchronous reads for hot keys
final cached = await SharedPreferencesWithCache.create(
  cacheOptions: const SharedPreferencesWithCacheOptions(
    allowList: {'theme_mode', 'locale'},
  ),
);
final locale = cached.getString('locale');

On Android the new APIs default to DataStore, which is a different file from the legacy XML store. If you switch an existing app, values written by the old API are not visible to the new one until you migrate them. The package ships a helper for this, migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary, in package:shared_preferences/util/legacy_to_async_migration_util.dart. Run it once at startup before the first read, with a migration-complete key so it does not repeat.

Hive and the hive_ce fork: fast local objects

Hive is a pure-Dart key-value and object store that writes boxes to files in the app directory. It is fast, works without native code, and runs on the web on top of IndexedDB. It is a good fit for caches and drafts that you read often and that would be awkward as rows in SQLite.

The maintenance situation matters before you add it. The original hive package has not had a release since 2.2.3 in 2022, and the planned Hive 4 rewrite never reached a stable release. The community fork hive_ce (Hive Community Edition) is actively maintained, keeps the familiar API, and supports current Dart and Flutter versions, including code generation for adapters with hive_ce_generator. For new code in 2026 we would choose hive_ce, or SQLite through drift when the data is relational or needs queries.

Hive can encrypt a box with AES-256. The important part is where the key lives: generate it once, keep it in secure storage, and never in preferences or in source.

import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';

const _secure = FlutterSecureStorage();

Future<Box<String>> openDraftsBox() async {
  await Hive.initFlutter();
  var encoded = await _secure.read(key: 'drafts_box_key');
  if (encoded == null) {
    encoded = base64UrlEncode(Hive.generateSecureKey());
    await _secure.write(key: 'drafts_box_key', value: encoded);
  }
  return Hive.openBox<String>(
    'drafts',
    encryptionCipher: HiveAesCipher(base64Url.decode(encoded)),
  );
}

Hive encrypts values, not keys, so do not put sensitive data in box keys. And encryption at rest does not help if a compromised app process reads the open box; it protects against offline access to files.

flutter_secure_storage: Keychain and Keystore

flutter_secure_storage stores small strings using each platform's protected store:

  • iOS and macOS: the Keychain. You choose accessibility through IOSOptions(accessibility: ...), for example KeychainAccessibility.first_unlock so background tasks can read a token after the first unlock since boot, or unlocked_this_device to prevent the item migrating to another device through backups.
  • Android: values are encrypted with keys held in the Android Keystore, which on most modern devices is hardware-backed. Older versions of the plugin offered an encryptedSharedPreferences option built on Jetpack Security, a library Google has since deprecated; recent major versions of the plugin reworked the Android storage. Read the changelog before upgrading across majors, because the on-device format and migration path changed.
  • Web: values are encrypted with WebCrypto, but the key lives in the browser's storage too. Treat it as obfuscation, not as protection against anyone who can run script in your origin.
const storage = FlutterSecureStorage(
  iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock_this_device),
);

Future<void> saveRefreshToken(String token) =>
    storage.write(key: 'refresh_token', value: token);

Future<String?> readRefreshToken() => storage.read(key: 'refresh_token');

Keep secure storage for secrets only. Each read crosses the platform channel and touches the Keychain or Keystore, which is noticeably slower than a preferences read, and it is not designed for kilobytes of JSON.

The traps after you ship

iOS keeps Keychain items after uninstall

Deleting the app removes its sandbox, including preferences and Hive files, but Keychain items can survive and reappear on reinstall. Users who reinstall to "log out" find themselves logged in, or the app finds a token for an account whose local data is gone. The common pattern is a first-run flag in preferences:

Future<void> clearSecretsOnFreshInstall() async {
  final prefs = SharedPreferencesAsync();
  final seen = await prefs.getBool('has_run_before') ?? false;
  if (!seen) {
    await const FlutterSecureStorage().deleteAll();
    await prefs.setBool('has_run_before', true);
  }
}

This works because preferences are deleted on uninstall while the Keychain is not.

Android backups restore data the Keystore cannot decrypt

Android Auto Backup copies app files, including encrypted secure-storage data, to the user's cloud backup and restores it on a new device. Keystore keys are never backed up. After a restore, the app has ciphertext and no key, and reads throw decryption errors. Exclude secure-storage files from backup with android:fullBackupContent (Android 11 and lower) and android:dataExtractionRules (Android 12 and higher), or set android:allowBackup="false" if you do not need backups at all. Whatever you choose, wrap reads in a try-catch that deletes the unreadable value and sends the user through login again, instead of crashing on launch.

Logging out must clear all three buckets

Our first logout only deleted the token. Cached customer records stayed in Hive for the next person using a shared device. Logout now deletes the secure items, deletes and recreates Hive boxes that contain user data, and clears user-scoped preference keys.

Decision checklist

  • Would it be a problem if someone read this value from a backup or a rooted phone? Secure storage.
  • Is it a small setting that only changes app behaviour? Preferences.
  • Is it a collection of records, or larger than a few KB? Hive (hive_ce) or SQLite, encrypted with a key from secure storage if it is user data.
  • Does the server need to trust it? Then the device is the wrong source of truth; keep it server-side and cache only what the UI needs.

Key takeaways

  • Decide the bucket (preference, app data, secret) before picking a package.
  • shared_preferences is plain text on every platform; prefer SharedPreferencesAsync or SharedPreferencesWithCache in new code and migrate legacy keys deliberately.
  • The original hive package is unmaintained; use the hive_ce fork or SQLite, and keep Hive encryption keys in secure storage.
  • flutter_secure_storage maps to Keychain and Keystore; it is for small secrets, and its web implementation is not a real security boundary.
  • Handle iOS Keychain persistence after uninstall and Android backup restore before users hit them.
  • Logout must clear secrets, user data boxes and user-scoped preferences.

References