skip to content

After a user reinstalls or restores your Flutter app, what can happen to tokens saved with flutter_secure_storage on iOS and Android, and how do you handle it?

level: seniorimportance: must knowfreq 45%

answer

  1. Keychain items can outlive the app
  2. first-run flag in shared_preferences
  3. Auto Backup restores the prefs file only
  4. Failed to unwrap key
  5. resetOnError defaults to true

basics

~20 s

On iOS, Keychain items can survive uninstall, so a reinstalled app may find an old token; clear secure storage on first run. On Android, a restored backup brings the encrypted file but not the Keystore key, so values cannot be decrypted.

solid answer

~40 s

The two platforms fail in opposite directions. On iOS the Keychain belongs to the system, and deleting an app has not reliably removed its Keychain items, so a reinstalled wallet can start already holding the previous user's refresh token. The usual guard is a first-run flag in `shared_preferences`, which is removed with the app: if it is missing, call `deleteAll()` before reading. On Android, flutter_secure_storage keeps encrypted values in a SharedPreferences file that Auto Backup can restore, while the Keystore key that wraps the AES key is not restored, so decryption fails with errors such as `Failed to unwrap key`. Version 10 defaults `resetOnError` to true, so the plugin deletes the unreadable data instead of throwing forever. Disable backup or exclude that file, and always treat a missing token as signed out.

code

dart · 17 lines
dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';

Future<String?> loadRefreshToken(FlutterSecureStorage storage) async {
  final prefs = SharedPreferencesAsync();
  if (await prefs.getBool('installed') != true) {
    // Fresh install: Keychain items from an earlier install may remain.
    await storage.deleteAll();
    await prefs.setBool('installed', true);
  }
  try {
    return await storage.read(key: 'wallet_refresh_token');
  } on Exception {
    // Unreadable after a restore: treat as signed out.
    return null;
  }
}

go deeper

for a junior

Recall that secure storage does not behave like normal app files on reinstall, and that the app must cope with a missing token.

for a middle

Explain the Keychain-outlives-app behaviour, the first-run flag pattern, and why an Android restore breaks decryption.

for a senior

Design the full handling: backup exclusion, resetOnError policy, device-bound accessibility and treating storage errors as sign-out.

for a principal

Decide which guarantees must come from the server, such as refresh-token revocation, because no device store can promise deletion.

## Why reinstall and restore are interview favourites Secure storage is backed by operating-system facilities that do not share the app's lifecycle. Deleting an app, reinstalling it and restoring a device backup each touch the app's files and the system's key stores differently, and **flutter_secure_storage** inherits every difference. A crypto-wallet app is the sharpest example: finding the wrong refresh token after a reinstall, or losing a valid one after a restore, is a security or support problem. ## iOS: data that outlives the app On iOS, each value is a **Keychain item** owned by the system and scoped to the app's access group. Deleting an app has historically **not** removed its Keychain items, and Apple does not document either behaviour as guaranteed, so the app must work whichever way it goes. The consequence: - A user deletes the wallet, reinstalls it later, and the app reads a refresh token from the previous installation, possibly for a different account. - Items written with a `_this_device` accessibility do not travel to a **new** device through a backup, but they may still survive a delete and reinstall on the **same** device. The standard guard uses the fact that ordinary app files are removed on uninstall: 1. On launch, read a flag such as `installed` from `shared_preferences`. 2. If it is missing, this is a fresh install: call `storage.deleteAll()` and then set the flag. 3. Only then read the refresh token. ## Android: data that cannot be decrypted On Android, flutter_secure_storage 10 keeps encrypted values and the wrapped AES key in a private **SharedPreferences** file (named `FlutterSecureStorage` by default), while the RSA key that unwraps the AES key lives in the **Android Keystore**. Android's Auto Backup can copy the preferences file to the cloud and restore it on a reinstall or a new device, but Keystore keys are not part of that backup. After a restore, the app has ciphertext and no key. The package's README calls this out: backups can cause `java.security.InvalidKeyException: Failed to unwrap key`. | Event | iOS (Keychain) | Android (prefs file plus Keystore) | |---|---|---| | Uninstall, reinstall, no backup | items may still be there | everything gone | | Reinstall with a restored backup | items may still be there | file restored, key missing, decryption fails | | New device from a backup | non-`_this_device` items can migrate | file restored, key missing, decryption fails | ## The resetOnError decision Version 10 changed `AndroidOptions.resetOnError` to default to **true**. When a storage operation fails with a key problem, the plugin logs the error, deletes the affected data (or all data) and retries, so `read` ends up returning `null`. With `resetOnError: false`, the same situation throws on every call and the app must call `deleteAll()` itself. Either way the secret is gone; the setting only decides whether your code or the plugin clears it. ## A robust pattern for the wallet - Treat **"token missing"** and **"storage threw"** as the same state: signed out, show sign-in, never crash. - Exclude the secure-storage file from Android backup, or set `android:allowBackup="false"` in `AndroidManifest.xml` as the package README suggests, so a restore does not bring back undecryptable data. - Run the first-run check on iOS before any secure read. - Prefer `_this_device` accessibility for session tokens on iOS so a restored phone starts clean. - Make the server able to revoke refresh tokens, since a device can never guarantee deletion. ## Testing these paths before users do - On iOS, delete the app from a test device, reinstall it, and confirm the first-run cleanup runs before any token is used. - On Android, use the platform's backup tooling to back up and restore the app on a test device, then launch it and confirm the app lands on sign-in instead of crashing or looping. - In widget tests, `FlutterSecureStorage.setMockInitialValues({...})` can simulate a leftover token, and a fake that throws can simulate an unreadable store, so both branches of the startup logic stay covered. ## What interviewers listen for A strong answer names the asymmetry (iOS keeps too much, Android keeps too little), the mechanism behind each, and a concrete handling strategy, rather than claiming that secure storage is always wiped on uninstall.

  • Why does the first-run check use shared_preferences rather than flutter_secure_storage itself?
    The check needs a marker that is removed on uninstall. Ordinary app files, including shared_preferences, go with the app, while Keychain items may not. A marker in secure storage could survive the reinstall and hide exactly the case you are trying to detect.
  • In flutter_secure_storage 10, what is the practical difference between resetOnError true and false after a failed decryption?
    With true, the default, the plugin deletes the corrupted data and retries, so the read returns null and the app sees a signed-out state. With false, every call keeps throwing until your code calls deleteAll(). The secret is unrecoverable in both cases.

saying these in an interview costs you the question

  • Uninstalling an iOS app always wipes its Keychain items.
  • Android Auto Backup restores the Keystore keys along with app files.
  • resetOnError: false lets you recover the old token after a restore.
  • A first-run flag should itself be kept in secure storage.
  • A missing token after reinstall means the plugin is broken.