Security & ComplianceAug 30, 2024 · 2 min read

A One-File Wrapper Around flutter_secure_storage That Fails Closed, Not Open

When secure storage throws — a Keystore invalidated by a fingerprint change, a Keychain error after a restore — most apps quietly fall back to a default. That default should never be 'logged in.'


flutter_secure_storage reads from the Android Keystore or iOS Keychain, and both of those can fail in ways that have nothing to do with your code: a user re-enrolls their fingerprint and Android invalidates the key tied to the old one, a device restore leaves the Keychain in a state it can't decrypt, an OS update changes how a secure enclave entry resolves. When that read throws, what your app does next matters more than almost anything else in the auth flow.

The failure mode #

A common shape, written with good intentions:

bad_example.dart
Future<String?> getAuthToken() async {
  try {
    return await _storage.read(key: 'auth_token');
  } catch (_) {
    return null; // "safe" fallback
  }
}

null here usually gets treated as "no token found" — which most auth guards interpret the same way as "not logged in yet," routing to a normal login screen. That sounds safe. It's the wrong kind of safe: it fails into a state that's indistinguishable from a fresh install, when what actually happened was a storage error, not an absence of credentials. On some flows, especially anything that caches partial session state elsewhere (a Riverpod provider that already has a cached user object, a Bloc that hasn't been told to reset), that ambiguity is exactly where a stale, unauthenticated-looking screen can sit on top of state that's still partially live.

The fix: make the failure mode explicit #

secure_session_store.dart
sealed class SessionReadResult {}
class SessionFound extends SessionReadResult {
  SessionFound(this.token);
  final String token;
}
class SessionAbsent extends SessionReadResult {}
class SessionReadFailed extends SessionReadResult {
  SessionReadFailed(this.error);
  final Object error;
}
 
class SecureSessionStore {
  SecureSessionStore(this._storage);
  final FlutterSecureStorage _storage;
 
  Future<SessionReadResult> readSession() async {
    try {
      final token = await _storage.read(key: 'auth_token');
      return token == null ? SessionAbsent() : SessionFound(token);
    } catch (error) {
      return SessionReadFailed(error);
    }
  }
}

Now the caller can't accidentally collapse "no session" and "couldn't check" into the same branch — the type forces a decision:

switch (await store.readSession()) {
  case SessionFound(:final token):
    // proceed authenticated
  case SessionAbsent():
    // route to normal login
  case SessionReadFailed():
    // force a full re-auth, log it, never assume "logged out is safe enough"
}

Compliance note

"Fail closed" means the failure path is at least as strict as the success path requires, never looser. A storage read error should force re-authentication — the same or stronger bar than a normal login — not quietly hand back whatever the least-friction UI state happens to be.

The wrapper itself is almost nothing. The value is entirely in refusing to let a storage exception disappear into a boolean.

Stuck on this in your own app?

This is the kind of problem I help fintech teams get right the first time — see how App Architecture & Consulting engagements work, or just tell me what you're building.