A Flutter edge case in ChainCare — a blockchain-based medical records system
When we added biometric authentication to ChainCare, the basic flow worked fine. User opens the app, fingerprint prompt appears, they authenticate, they're in. Simple enough.
The requirement we had was a bit more than that though. We wanted the app to lock whenever the user leaves it — like a banking app would — and require biometric re-authentication when they come back. That's when things got weird.
What was happening
The loop looked like this:
- User is in the app
- Fingerprint prompt appears
- User authenticates successfully
- Prompt dismisses
- Another fingerprint prompt immediately appears again
- Repeat forever
The authentication was succeeding — it just kept re-triggering itself. One successful fingerprint scan produced another prompt, indefinitely.
The naive implementation that caused it
The code was straightforward — listen to the app lifecycle, and when the app resumes, authenticate:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_authenticate(); // this causes the loop
}
}
Looks reasonable. The problem is in what AppLifecycleState.paused actually means.
What I got wrong about AppLifecycleState
I assumed paused meant "the user pressed the home button and left the app." That's not what it means.
paused fires any time another native layer takes full focus over the Flutter surface. Including the system biometric prompt.
So here's what was actually happening:
- Fingerprint prompt appears → OS takes focus → Flutter fires
paused - User authenticates → prompt dismisses → Flutter fires
resumed - The
resumedhandler fires_authenticate()again - New fingerprint prompt appears → loop restarts
From Flutter's perspective, there's no difference between the user leaving the app for 10 minutes and the biometric prompt appearing for 2 seconds. Both produce the same paused → resumed sequence.
The fix
The fix came from a simple observation: a biometric prompt takes 1–5 seconds. A genuine background session — user switches away, checks something, comes back — is almost always longer than that.
So instead of re-locking on every resume, we only re-lock if the app was backgrounded for longer than 60 seconds. Anything shorter is treated as a transient OS pause — like the biometric prompt — and ignored.
The implementation records a timestamp when the app pauses, and checks elapsed time on resume:
static DateTime? _pausedAt;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.paused:
_pausedAt = DateTime.now();
break;
case AppLifecycleState.resumed:
_handleResume();
break;
default:
break;
}
}
Future<void> _handleResume() async {
if (_pausedAt != null) {
final seconds =
DateTime.now().difference(_pausedAt!).inSeconds;
if (seconds > 60) {
// user genuinely left — re-lock
setState(() => _showLockScreen = true);
await _authenticate();
}
// under 60s — probably the biometric prompt, do nothing
}
_pausedAt = null;
}
That's the core of it. Once we added the threshold check, the loop stopped completely. Successful authentication stays successful.
What I actually learned from this
The main thing: AppLifecycleState.paused doesn't mean "user left the app." It means "another native layer has focus." If you're building anything that hooks into the lifecycle — lock screens, background timers, session expiry — that distinction matters a lot.
The second thing: measuring elapsed time is a lot more reliable than trying to track intent with flags. Setting a _isShowingBiometricPrompt flag before calling local_auth sounds reasonable, but async gaps and exceptions can corrupt it. A timestamp is just a fact — it doesn't depend on your code running in a specific order.
This came up during ChainCare, our final-year project — a blockchain-based medical records system. My part was the authentication and session management module. Hitting this bug and finding the fix was probably the most interesting technical problem I worked through during the whole project.
If you've hit this loop or something similar, the full ChainCare codebase is on GitHub.

Top comments (0)