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:
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 #
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.