> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/secure-storage-keychain-keystore.md).

# Secure Storage: Keychain and Keystore

How I preserve the old state when iOS Keychain is temporarily unavailable and prevent a Secure Storage write from destroying data

## Result

In my app, Secure Storage does more than hold a few isolated strings. It also sits underneath the persisted-state restoration flow during startup. If the storage has not returned the old data but the app continues with an empty state, the next persistence cycle can write over data that I still need to preserve.

I handle this situation with three layers:

1. Retry only the error that indicates iOS Keychain is temporarily inaccessible.
2. If the old state still cannot be loaded, suspend persistence instead of treating it as an empty state.
3. Before every non-null write on iOS, read the same key first. If that read throws, refuse the write and preserve the old data.

The following flow shows how these layers work together:

```
App starts
    │
    ▼
Load Secure Storage
    ├── Success ─────────────► Hydrate state ─► Allow persistence
    │
    └── Temporary Keychain error
                  │
                  ├── Retry with a limit
                  │
                  └── Still failing ──► Suspend persistence
                                                │
                                                ▼
                                      Resume or user retry

Before an iOS write
       │
       ▼
Read with the same key and options
       ├── Value or null ─► Allow write
       └── Exception ─────► Refuse write, preserve old data
```

My goal is not to claim that Keychain can never lose data. This approach prevents a new state from entering the failure path I identified: the storage cannot return the old data, but the app continues persisting anyway.

This article applies to both Android and iOS, but the write gate is enabled only on iOS. I will explain that choice instead of applying the same workaround to every platform.

## Problem

When I first used `flutter_secure_storage`, the API looked straightforward:

```dart
await storage.write(key: 'session', value: encodedState);
final value = await storage.read(key: 'session');
await storage.delete(key: 'session');
```

I initially treated storage handling as a few `read`, `write`, and `delete` calls. The real failure, however, does not start only when the app writes. It begins when the app cannot load the old data.

### How are Secure Storage, Keychain, and Keystore different?

I only need three concepts to explain the implementation in this article:

| Concept          | How I use it in this article                                                                                                                                        |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Secure Storage   | A key-value API exposed by a Flutter package. It is an abstraction, not one identical storage area on every platform.                                               |
| iOS Keychain     | An Apple service that stores sensitive items and controls when the app may access them.                                                                             |
| Android Keystore | A system that protects cryptographic keys and restricts how those keys may be used. Keystore is not simply a place that directly stores every token or data string. |

For `flutter_secure_storage` 10, this mental model is enough:

```
Flutter code
     │
     ▼
flutter_secure_storage
     ├── iOS ─────► Keychain item
     └── Android ─► Encrypted value + key protected by Keystore
```

The platform documentation describes Keychain Services, Android Keystore, and the encryption algorithms more completely than this article needs. I focus on how the app reacts when that backend has not returned its data.

### Unreadable does not mean missing

On iOS, a Keychain item's accessibility determines when the app may read it. I use `first_unlock_this_device`, so after the device restarts, the item remains inaccessible until the user unlocks the device for the first time.

When Keychain does not yet permit access, the app may receive `errSecInteractionNotAllowed` with OSStatus `-25308`. If I convert this exception into `null`, the app can no longer distinguish between two cases:

* The key genuinely does not exist.
* The key contains data but cannot currently be read.

That distinction matters. A missing key may be created. A storage backend that has not answered should not be treated as a blank page ready to be overwritten.

### A write can enter a delete-then-add path

I inspected the exact Darwin implementation `0.2.0` pulled in by `flutter_secure_storage` 10.0.0. When it writes an existing item, this implementation performs the following sequence:

```
Check item
    │ exists
    ▼
SecItemUpdate
    │ error
    ▼
SecItemDelete
    │
    ▼
SecItemAdd
```

If the update fails, the plugin deletes the item and creates it again. If the following add operation also fails, the old data may already have been deleted.

This makes an otherwise ordinary flow dangerous:

```
Keychain read fails
        │
        ▼
App uses initial state
        │
        ▼
Redux dispatches an action
        │
        ▼
Persist the new state
        │
        ▼
Update fails → Delete → Add fails
        │
        ▼
Old data may be lost
```

Putting a `try/catch` around `write()` alone does not solve the entire problem. I need to protect the complete `load → hydrate → app runs → save` chain.

### `null` and an exception require different handling

I preserve all three outcomes of `read()` instead of collapsing them into two:

| Read result         | Meaning in my implementation         | Action           |
| ------------------- | ------------------------------------ | ---------------- |
| Returns a value     | The item exists and is readable      | Allow the write  |
| Returns `null`      | The key may not exist yet            | Allow creation   |
| Throws an exception | The storage did not answer the query | Refuse the write |

I do not block every `null` result. Doing so would prevent a fresh installation from storing its first key. My blocking condition is an actual exception from the read.

## Solution

### Configure the package and shared accessibility options

The version I use in the app is:

```yaml
dependencies:
  flutter_secure_storage: 10.0.0
```

I create an adapter that fixes the iOS options in one place. Both `read` and `write` therefore use the same query configuration:

```dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

abstract interface class SecretStore {
  Future<String?> read(String key);
  Future<void> write(String key, String value);
  Future<void> delete(String key);
}

final class FlutterSecretStore implements SecretStore {
  FlutterSecretStore({FlutterSecureStorage? storage})
      : _storage = storage ??
            const FlutterSecureStorage(iOptions: _iosOptions);

  static const _iosOptions = IOSOptions(
    accessibility: KeychainAccessibility.first_unlock_this_device,
  );

  final FlutterSecureStorage _storage;

  @override
  Future<String?> read(String key) {
    return _storage.read(key: key);
  }

  @override
  Future<void> write(String key, String value) {
    return _storage.write(key: key, value: value);
  }

  @override
  Future<void> delete(String key) {
    return _storage.delete(key: key);
  }
}
```

`first_unlock_this_device` has two characteristics I need to consider:

* After the user unlocks the device for the first time following a restart, the item remains accessible until the next restart.
* The item does not migrate to another device when the user restores a backup.

I do not copy this option into every call site. If a read and a write use different options, the pre-read no longer checks the same item that the write is about to touch.

### Retry only a temporarily locked Keychain

Not every exception should be retried. I retry only `errSecInteractionNotAllowed` and let other failures surface immediately:

```dart
import 'package:flutter/services.dart';

bool isInteractionNotAllowed(Object error) {
  if (error is! PlatformException) return false;

  if (error.details == -25308) return true;

  final text = '${error.code} ${error.message} ${error.details}';
  return text.contains('InteractionNotAllowed') || text.contains('-25308');
}

final class RetryingSecretReader {
  const RetryingSecretReader({
    required this.storage,
    this.maxRetries = 4,
    this.retryDelay = const Duration(milliseconds: 500),
  });

  final SecretStore storage;
  final int maxRetries;
  final Duration retryDelay;

  Future<String?> read(String key) async {
    for (var attempt = 0; ; attempt++) {
      try {
        return await storage.read(key);
      } catch (error) {
        final canRetry =
            isInteractionNotAllowed(error) && attempt < maxRetries;

        if (!canRetry) rethrow;
        await Future<void>.delayed(retryDelay);
      }
    }
  }
}
```

With this configuration, the app makes one initial read and at most four retries, spaced 500 ms apart. An authentication failure or a bad configuration will not repair itself merely because the app waits longer, so I do not retry those errors.

The text fallback for `-25308` still depends on how the package formats its exception. I inspect `PlatformException.details` first and retain string matching only as a fallback for plugin versions without a stable error model.

### Suspend persistence when the old data cannot be loaded

Exhausting the retry budget does not mean the old state was empty. I keep a separate status so the middleware knows that storage is not ready:

```dart
final class SafeStatePersistence {
  SafeStatePersistence({
    required this.key,
    required this.storage,
    required this.reader,
  });

  final String key;
  final SecretStore storage;
  final RetryingSecretReader reader;

  bool _savingSuspended = true;
  bool get savingSuspended => _savingSuspended;

  Future<String?> load({required bool isFirstInstall}) async {
    try {
      final value = await reader.read(key);

      // Null is normal on first install. Otherwise, I do not write the
      // initial state over a key that has unexpectedly disappeared.
      _savingSuspended = value == null && !isFirstInstall;
      return value;
    } catch (_) {
      _savingSuspended = true;
      rethrow;
    }
  }

  Future<void> save(String encodedState) async {
    if (_savingSuspended) {
      throw StateError('Persist is suspended until storage is recovered');
    }

    await storage.write(key, encodedState);
  }

  Future<void> wipe() async {
    await storage.delete(key);
  }
}
```

In the Redux middleware, I check `savingSuspended` before scheduling a save. When the app returns to `resumed` or the user taps retry, the app calls `load()` again. Only a successful load re-enables persistence and hydrates the state that was read.

I identify the first installation with a marker outside Secure Storage. This is a policy decision, not default behavior that every app should copy:

* On the first installation, `null` is normal and the app may create a new state.
* If it is not the first installation but the persisted key returns `null`, I treat that as unexpected and do not write yet.
* During uninstall/reinstall, backup restoration, or a device change, the marker and secure data can have different lifecycles. I need to test each flow before deciding to wipe.

State serialization, migration, throttling, and whitelisting belong in a separate Redux Persist article. The important point here is that the middleware must not save an initial state while the old storage remains unresolved.

The Redux Persist article covers the complete persistence boundary above storage, including versioned schemas, locking, hydration, and failure-path tests.

{% content-ref url="/pages/rmFKA2Mf6X0rir3aOGF7" %}
[Redux Persist and State Migration](/flutter/my-flutter/architecture-state/redux-persist-state-migration.md)
{% endcontent-ref %}

### Refuse an iOS write when the pre-read throws

I put the write gate around `SecretStore` instead of asking every feature to remember the check:

```dart
final class SecureStorageWriteRefused implements Exception {
  const SecureStorageWriteRefused(this.key, this.cause);

  final String key;
  final Object cause;

  @override
  String toString() {
    return 'SecureStorageWriteRefused: storage was not readable';
  }
}

typedef WriteRefusedCallback = void Function(
  String key,
  Object error,
  StackTrace stackTrace,
);

final class GatedSecretStore implements SecretStore {
  const GatedSecretStore({
    required this.delegate,
    required this.guardWrites,
    this.onWriteRefused,
  });

  final SecretStore delegate;
  final bool guardWrites;
  final WriteRefusedCallback? onWriteRefused;

  @override
  Future<String?> read(String key) {
    return delegate.read(key);
  }

  @override
  Future<void> write(String key, String value) async {
    if (guardWrites) {
      try {
        await delegate.read(key);
      } catch (error, stackTrace) {
        onWriteRefused?.call(key, error, stackTrace);
        throw SecureStorageWriteRefused(key, error);
      }
    }

    await delegate.write(key, value);
  }

  @override
  Future<void> delete(String key) {
    return delegate.delete(key);
  }
}
```

I enable the gate by platform:

```dart
import 'dart:io';

final secretStore = GatedSecretStore(
  delegate: FlutterSecretStore(),
  guardWrites: Platform.isIOS,
  onWriteRefused: (key, error, stackTrace) {
    // Send only the required metadata to the error-monitoring system.
    // Never log the value, token, or secret.
  },
);
```

The gate has three deliberate behaviors:

* `read()` returns a value: continue with the write.
* `read()` returns `null`: continue with the write so a new key can be created.
* `read()` throws an exception: stop before invoking the platform write.

I keep `delete()` separate from `write()`. An encoder that accidentally returns `null` must not turn into a delete operation. Only an explicit wipe or logout flow calls delete.

### `await` the real write and do not swallow low-level failures

A helper returning `Future<void>` must wait for the platform write to complete:

```dart
Future<void> saveSession(String encodedState) async {
  await secretStore.write('session', encodedState);
}
```

If the helper starts a write without awaiting it, `await saveSession()` at the calling layer no longer guarantees that the data reached storage.

I keep the low-level API on a `writeOrThrow` contract: the caller needs to know whether storage accepted the write. For a fire-and-forget call site, I can add a separate best-effort wrapper that captures the failure and sends telemetry, but I do not make that behavior the default for data that must be stored successfully.

When recording a refusal, I never log the value. A key name may also reveal business information, so in production I prefer a neutral key category or a stable hash over the raw identifier.

### Serialize load and save operations

The write gate protects the iOS failure path, but the app can still introduce errors when two saves overlap. In my persistor, every storage access and every update to the last-payload record goes through the same lock.

I preserve these rules:

* A load cannot interleave with a save while it updates the payload record.
* A later save compares itself only with data that has actually landed, not with an in-flight write.
* If a write fails, the comparison cache is cleared so the same payload can be retried.
* An unchanged payload does not create another unnecessary Keychain write.

I do not include the complete Redux persistor in this article. This is only an additional protection that prevents a persistence-layer race condition from undermining the write gate.

### Android configuration

On Android, `flutter_secure_storage` 10.0.0 uses RSA OAEP by default to protect or wrap the AES key and uses AES-GCM to encrypt the data. I therefore do not describe the behavior as "storing a token directly in Keystore."

I also disable Android Auto Backup for the app:

```xml
<application
    android:allowBackup="false"
    android:fullBackupContent="false"
    ...>
</application>
```

If encrypted data is restored but the corresponding cryptographic key is no longer present in Keystore, the app may be unable to decrypt the data and can encounter an unwrap-key error.

The write gate is not enabled on Android because I have not found the same `update → delete → add` failure path in the Android implementation. Adding an extra platform read before every write without a corresponding failure mode would only increase I/O.

### iOS configuration

On iOS, the adapter uses one configuration:

```dart
const IOSOptions(
  accessibility: KeychainAccessibility.first_unlock_this_device,
);
```

My app does not pass a custom Keychain access group for this storage. `App Groups` and `Keychain Sharing` are different capabilities, and I do not use the terms interchangeably.

If you need to share a Keychain item between multiple apps or an extension, configure an access group according to the package documentation and your own signing entitlements. Do not copy an access group from another app.

### Verify the result

By placing the platform API behind `SecretStore`, I can test the write gate without a real Keychain:

```dart
import 'package:flutter_test/flutter_test.dart';

final class FakeSecretStore implements SecretStore {
  String? value;
  Object? readError;
  final calls = <String>[];

  @override
  Future<String?> read(String key) async {
    calls.add('read');
    final error = readError;
    if (error != null) throw error;
    return value;
  }

  @override
  Future<void> write(String key, String newValue) async {
    calls.add('write');
    value = newValue;
  }

  @override
  Future<void> delete(String key) async {
    calls.add('delete');
    value = null;
  }
}

void main() {
  test('preserves the old value when the pre-read throws', () async {
    final platform = FakeSecretStore()
      ..value = 'old'
      ..readError = Exception('storage unavailable');

    final storage = GatedSecretStore(
      delegate: platform,
      guardWrites: true,
    );

    await expectLater(
      storage.write('session', 'new'),
      throwsA(isA<SecureStorageWriteRefused>()),
    );

    expect(platform.value, 'old');
    expect(platform.calls, ['read']);
  });

  test('allows creation when the pre-read returns null', () async {
    final platform = FakeSecretStore();
    final storage = GatedSecretStore(
      delegate: platform,
      guardWrites: true,
    );

    await storage.write('session', 'new');

    expect(platform.value, 'new');
    expect(platform.calls, ['read', 'write']);
  });
}
```

Beyond these two tests, I keep the following cases in the storage and persistor test suites:

* A readable value allows the write.
* The gate runs only on iOS; Android does not incur the extra read.
* Delete is not represented as a write with a `null` value.
* Load retries `-25308` and returns the value when storage becomes available.
* Load does not retry authentication failures or unrelated errors.
* The persistor does not save after a failed load.
* The persistor resumes only after a later load succeeds.
* A helper does not complete before the platform write finishes.
* A failed write does not cause a retry with the same payload to be skipped.

The manual checks should include restarting the device before the first unlock, backgrounding and reopening the app, logout, uninstall/reinstall, and backup restoration. A simulator cannot reproduce every Keychain lifecycle and protection state, so the important cases need to run on a real device before publishing the test results.

### Common mistakes and trade-offs

#### Converting every read error into `null`

The symptom is that the app opens with an initial state and then loses the old session or settings after the next action. The exception was converted into "no data." I preserve the exception until a layer with enough context can retry or suspend persistence.

#### Blocking when `read()` returns `null`

If the gate rejects `null`, a fresh installation cannot create its first key. I refuse a write only when the pre-read throws.

#### Using `write(key, null)` as a convenience API

In many wrappers, `null` means delete. A serialization error can accidentally remove a key. I separate `write(String value)` and `delete()` into different methods.

#### Swallowing an exception in the shared helper

The app may keep running while the caller assumes that the data was saved. My low-level storage rethrows. I add best-effort behavior only at a call site that truly accepts a missed write and always records telemetry.

#### Retrying every error

An authentication failure, a bad entitlement, or corrupted data is not repaired by a few delays. I retry only an identified transient error and cap the retry budget.

#### Treating the pre-read as a transaction

Read-before-write adds one platform round trip and is not atomic. Keychain can still change state between the two operations. This is a risk-reduction layer for an observed failure mode, not an absolute transaction.

#### Disabling the write gate remotely

An emergency switch can help if the workaround causes an unexpected production issue. I keep the safe default: if remote configuration does not load, the gate remains enabled. Disabling it reopens the old failure path, so the switch needs telemetry and a plan for re-enabling it.

### Related article

Secure Storage protects persisted data; it does not decide when the person holding the device may continue in the app. When I need a local gate before reusing a session, I keep the biometric authentication flow in a separate article. That gate does not encrypt data by itself or bind an encryption key to the authentication result.

{% content-ref url="/pages/svjjp8ZWjKE9gh0H7k7o" %}
[Biometric Authentication with local\_auth](/flutter/my-flutter/security-observability/biometric-authentication-local-auth.md)
{% endcontent-ref %}

### Verified versions

* Flutter: 3.41.2.
* Dart: 3.11.0.
* Reference app Android min SDK: 24.
* Reference app iOS deployment target: 15.0.
* `flutter_secure_storage`: 10.0.0.
* `flutter_secure_storage_darwin`: 0.2.0.

The package implementation can change between versions. Before keeping or removing the write gate, I inspect the native source again and run migration tests instead of changing only the constraint in `pubspec.yaml`.

### References

* [`flutter_secure_storage` 10.0.0](https://pub.dev/packages/flutter_secure_storage/versions/10.0.0)
* [Darwin implementation of `flutter_secure_storage` at tag 10.0.0](https://github.com/juliansteenbakker/flutter_secure_storage/blob/v10.0.0/flutter_secure_storage_darwin/darwin/flutter_secure_storage_darwin/Sources/flutter_secure_storage_darwin/FlutterSecureStorage.swift)
* [Apple — `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`](https://developer.apple.com/documentation/security/ksecattraccessibleafterfirstunlockthisdeviceonly)
* [Apple — SecItem pitfalls and best practices](https://developer.apple.com/forums/thread/724013)
* [Android — Android Keystore system](https://developer.android.com/privacy-and-security/keystore)

## Conclusion

After encountering this problem, I no longer treat Secure Storage as a `Map` with encryption added. The platform may not return data when the app needs to load it, and "unreadable" is not the same as "missing."

My solution is to retry the correct transient error, suspend persistence while the old state remains unresolved, and refuse an iOS write when the pre-read throws. It adds one platform call to every iOS write and does not provide an absolute transaction, but it prevents a new state from entering the failure path that can destroy old data.

If an app stores only an unimportant preference, this level of protection may be unnecessary. For a session, persisted state, or data that is difficult to recover, I want the failure to be visible and handled before another write is allowed.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
