DEV Community

Shehabin Sinad S
Shehabin Sinad S

Posted on • Originally published at Medium

The biometric authentication loop that took me a while to figure out

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:

  1. User is in the app
  2. Fingerprint prompt appears
  3. User authenticates successfully
  4. Prompt dismisses
  5. Another fingerprint prompt immediately appears again
  6. 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Fingerprint prompt appears → OS takes focus → Flutter fires paused
  2. User authenticates → prompt dismisses → Flutter fires resumed
  3. The resumed handler fires _authenticate() again
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

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)