skip to content

With flutter_secure_storage on iOS, how does IOSOptions accessibility decide when a stored token can be read, and why might a background refresh fail?

level: middleimportance: should knowfreq 30%

answer

  1. KeychainAccessibility enum
  2. default is unlocked
  3. first_unlock for background work
  4. _this_device never migrates
  5. update keeps the old attribute

basics

~20 s

IOSOptions.accessibility maps to the Keychain's kSecAttrAccessible. The default, unlocked, makes items readable only while the device is unlocked, so a background refresh on a locked phone fails; first_unlock allows reads after the first unlock since boot.

solid answer

~40 s

`IOSOptions(accessibility: ...)` takes a `KeychainAccessibility` value that the Darwin plugin maps to `kSecAttrAccessible`. The default is `unlocked` (`kSecAttrAccessibleWhenUnlocked`): readable only while the user has the device unlocked. A wallet that refreshes its session from a background task or a push while the phone is locked will get an error instead of the token. `first_unlock` makes the item readable after the first unlock following a reboot, which is the usual choice for background work. The `_this_device` variants (`unlocked_this_device`, `first_unlock_this_device`) and `passcode` never migrate to another device through a backup, and `passcode` also requires a device passcode. Because writing an existing key updates only its data, changing accessibility for existing items means deleting and rewriting them.

code

dart · 16 lines
dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const storage = FlutterSecureStorage(
  iOptions: IOSOptions(
    accessibility: KeychainAccessibility.first_unlock_this_device,
  ),
);

Future<void> migrateAccessibility(String key) async {
  // An update keeps the old attribute, so rewrite the item.
  const old = IOSOptions.defaultOptions;
  final value = await storage.read(key: key, iOptions: old);
  if (value == null) return;
  await storage.delete(key: key, iOptions: old);
  await storage.write(key: key, value: value);
}

go deeper

for a junior

Recall that iOS items have an accessibility setting and that the default only allows reads while the phone is unlocked.

for a middle

Explain each KeychainAccessibility value, the when versus where split, and why first_unlock suits background refresh.

for a senior

Diagnose intermittent locked-device failures, migrate existing items safely, and choose device-bound variants for session secrets.

for a principal

Balance availability for background features against exposure, and set one accessibility policy per class of secret across the app.

## What accessibility controls On iOS and macOS, **flutter_secure_storage** stores each value as a **Keychain item**. Every Keychain item carries an **accessibility** attribute, which the system enforces: it decides in which device states the item's data can be decrypted and returned. The package exposes it as `IOSOptions(accessibility: KeychainAccessibility.xxx)` (and `MacOsOptions` on macOS), and the Darwin implementation maps each value to Apple's `kSecAttrAccessible` constants. ## The values and what they mean | `KeychainAccessibility` | Keychain constant | Readable when | Moves to a new device via backup | |---|---|---|---| | `unlocked` (default) | `...WhenUnlocked` | device unlocked | yes | | `unlocked_this_device` | `...WhenUnlockedThisDeviceOnly` | device unlocked | no | | `first_unlock` | `...AfterFirstUnlock` | after first unlock since boot | yes | | `first_unlock_this_device` | `...AfterFirstUnlockThisDeviceOnly` | after first unlock since boot | no | | `passcode` | `...WhenPasscodeSetThisDeviceOnly` | unlocked, and only if a passcode is set | no | Two independent choices are packed into these names: - **When** the item is readable: only while unlocked, or from the first unlock after a reboot until the next reboot. - **Where** it may travel: the `_this_device` variants and `passcode` are bound to the device, so an encrypted backup restored to a new phone does not bring them along. ## Why a background refresh fails with the default A crypto-wallet app may renew its access token from a background fetch or when a silent push arrives. If the phone is locked at that moment and the refresh token was written with the default `unlocked` accessibility, the Keychain refuses to return it and the plugin surfaces an error (a `PlatformException`) rather than the value. The symptoms are intermittent: it works in testing because developers keep phones unlocked, and it fails in the field overnight. The fix is to write that specific item with `first_unlock` or `first_unlock_this_device`. Those remain readable while locked, as long as the user has unlocked the phone once since it booted. Items that only foreground code reads can keep the stricter default. ## Diagnosing the failure The failure is easy to misread, so it helps to know its signature: - It happens only on physical devices, only while the screen is locked, and never while a developer is watching the debugger with the phone awake. - The read throws a `PlatformException` carrying a Keychain status rather than returning `null`; code that treats every exception as "no token" signs the user out overnight for no visible reason. - Items written by older app versions keep their original attribute, so a fix shipped only in new code appears not to work for existing users until their items are rewritten. A reliable reproduction is to trigger the background task from Xcode while the device is locked, or to schedule it and lock the phone. Logging the result of `isCupertinoProtectedDataAvailable()` next to the failure confirms that the device state, not the key name or the options object, is the cause. ## Operational details 1. **Pass the option consistently.** Accessibility is part of the options you pass to `write`, and per-call options override the instance's. The simplest discipline is one `FlutterSecureStorage` instance with its `iOptions` set once. 2. **Existing items keep their attribute.** In the Darwin implementation, `write` on a key that already exists calls an update with only the new data, so the old accessibility stays. To migrate, read the value, `delete` it, and `write` it again with the new option. 3. **Check protected data.** `isCupertinoProtectedDataAvailable()` and the `onCupertinoProtectedDataAvailabilityChanged` stream report whether iOS currently allows protected data, which helps explain failures in background code. 4. **Sharing and sync are separate options.** `groupId` shares items with an app extension through a Keychain access group, and `synchronizable: true` opts items into iCloud Keychain. A wallet usually leaves sync off for session tokens. ## Choosing for a wallet - Refresh token used by background refresh: `first_unlock_this_device`, readable while locked and never restored onto another phone. - Tokens only read in the foreground: `unlocked_this_device`. - Anything that must exist only when a passcode is set: `passcode`, accepting that removing the passcode makes such items unavailable.

  • With flutter_secure_storage on iOS, what is the trade-off between first_unlock and first_unlock_this_device?
    Both allow reads while locked after the first unlock since boot. The `_this_device` variant is excluded from moving to another device through a backup, so a restored phone starts without the token and the user signs in again, which is usually what a wallet wants.
  • Why might a flutter_secure_storage read work in the foreground but fail from a background isolate or task on iOS?
    The difference is usually device state, not the isolate. Background work often runs while the phone is locked, and an item written with the default `unlocked` accessibility cannot be read then. Writing it with `first_unlock` fixes that.

Accessibility works like a bank's safe-deposit hours: the default opens the vault only while a teller (the unlocked screen) is present, while first_unlock keeps it open all night once the manager has unlocked the building that morning.

saying these in an interview costs you the question

  • The default accessibility lets background tasks read items while locked.
  • first_unlock means the item is readable even before any unlock after reboot.
  • Writing an existing key with new options always updates its accessibility.
  • _this_device items are restored to a new phone from an encrypted backup.
  • Accessibility is an Android option that also applies on iOS.