> 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/biometric-authentication-local-auth.md).

# Biometric Authentication with local\_auth

How I use local\_auth as a local gate before the real sign-in, make fallback explicit, and keep transient errors from destroying user preferences

## Result

In my app, biometrics do not create a login session by themselves. `local_auth` only opens a local authentication gate. The app continues to the real server sign-in only after that gate succeeds.

I organize the flow into three layers:

1. A preference records whether the user wants quick login.
2. `local_auth` asks the operating system to authenticate the person holding the device.
3. The server still verifies the login information and decides whether to issue a session.

```
User selects quick login
          │
          ▼
App checks the preference and runtime availability
          │
          ▼
local_auth opens the system prompt
    ├── cancel/error ─► stop, preserve the preference
    │
    └── success
          │
          ▼
Read the saved login information
          │
          ▼
Server authenticates
    ├── fail ────────► do not create a session
    └── success ─────► enter the app
```

The most important point is that `local_auth` does not replace server authentication. It also does not encrypt saved data automatically or prove a remote identity. The app only receives the result of a policy evaluated by the operating system.

The source I verified uses `local_auth 2.2.0` with `stickyAuth: true`, `useErrorDialogs: false`, and no explicit `biometricOnly` value. The actual behavior therefore prefers Face ID or fingerprint but can still fall back to the device PIN, pattern, or passcode. I preserve that behavior in this article and do not describe it as biometric-only.

## Problem

When I first added quick login, I only needed a `true` or `false` answer: sign in on success and stop on failure. A Boolean, however, cannot describe the full state of local authentication.

Before the system prompt appears, the app already needs to answer several different questions:

| State           | Question                                                                                     |
| --------------- | -------------------------------------------------------------------------------------------- |
| Capability      | Can the device evaluate the selected local policy?                                           |
| Enrollment      | Has the user enrolled Face ID, fingerprint, or the equivalent method?                        |
| Preference      | Did the user explicitly enable the feature in the app?                                       |
| Attempt outcome | Did the current attempt succeed, get canceled, reach lockout, or encounter a platform error? |

These states should not be collapsed. Canceling one attempt does not mean the user wants to disable the feature. A temporarily unavailable sensor also does not mean the device no longer supports local authentication.

### `isDeviceSupported()` does not mean biometrics are available

`local_auth 2.2.0` separates three APIs:

* `isDeviceSupported()` checks device-level authentication capability.
* `canCheckBiometrics` checks whether biometric authentication can be evaluated.
* `getAvailableBiometrics()` returns the enrolled biometric types visible to the plugin.

I do not use `isDeviceSupported()` as proof that the device has biometric hardware. I also do not use `BiometricType.face` or `BiometricType.fingerprint` alone as an authorization rule. The type list depends on the platform and package version; its icon is only a UX detail.

### The default is not biometric-only

With the version used in this article, `authenticate()` allows device credential fallback unless `biometricOnly: true` is set. That creates a clear trade-off:

* The user can still proceed when the sensor fails or biometrics are locked out.
* Anyone who knows the device PIN, pattern, or passcode can also pass the local gate.

This is a threat-model decision, not merely a technical option. If an operation must require biometrics, you need a different policy and must verify fallback behavior on real devices.

### Threat model for this article

This approach reduces the risk of someone holding an already unlocked device going directly through quick login without another authentication step. It also keeps server authentication as an independent layer after the local gate.

The article assumes that the operating system, system prompt, and plugin have not been spoofed; the app process has not been hooked to force a successful result; and the device has not been rooted or jailbroken. This approach does not protect against runtime instrumentation, binary tampering, malware, or abuse of an already active session.

A `true` result does not bind a cryptographic key to biometrics either. If your threat model requires a key to become usable only after authentication, use platform access-control or cryptographic APIs instead of relying only on the Boolean returned by `local_auth`.

## Solution

### Choose the policy before building the UI

This is the dependency version I verified:

```yaml
dependencies:
  local_auth: 2.2.0
```

I name policies after their behavior instead of their icon:

```dart
enum LocalAuthPolicy {
  biometricPreferred,
  biometricsOnly,
}

AuthenticationOptions optionsFor(LocalAuthPolicy policy) {
  return AuthenticationOptions(
    biometricOnly: policy == LocalAuthPolicy.biometricsOnly,
    stickyAuth: true,
    useErrorDialogs: false,
  );
}
```

`biometricPreferred` matches my current flow: the system prompt prefers biometrics but can use a device credential. `biometricsOnly` is for a feature with a stricter policy.

I do not select a policy based on whether the device shows a face or fingerprint icon. Product and security requirements must decide which fallback methods are acceptable for each operation.

### Separate availability, preference, and authentication outcomes

My original helper returned a `bool` and collapsed every exception into failure. That prevented the login flow from continuing, but the UI could not tell whether the user had canceled, had not enrolled biometrics, or was locked out.

For the public example, I separate the outcomes into meaningful values:

```dart
enum LocalAuthOutcome {
  success,
  rejectedOrCancelled,
  notEnrolled,
  lockedOut,
  unavailable,
  busy,
  failed,
}
```

The following adapter keeps `LocalAuthentication` behind one boundary instead of calling the plugin directly from multiple widgets:

```dart
import 'package:flutter/services.dart';
import 'package:local_auth/error_codes.dart' as auth_error;
import 'package:local_auth/local_auth.dart';

final class LocalAuthGate {
  LocalAuthGate(this._auth);

  final LocalAuthentication _auth;
  bool _authenticating = false;

  Future<bool> canOfferBiometric() async {
    try {
      final canCheck = await _auth.canCheckBiometrics;
      final deviceSupported = await _auth.isDeviceSupported();
      final enrolled = await _auth.getAvailableBiometrics();

      return canCheck && deviceSupported && enrolled.isNotEmpty;
    } on PlatformException {
      return false;
    }
  }

  Future<LocalAuthOutcome> authenticate({
    required LocalAuthPolicy policy,
    required String reason,
  }) async {
    if (_authenticating) return LocalAuthOutcome.busy;
    _authenticating = true;

    try {
      final accepted = await _auth.authenticate(
        localizedReason: reason,
        options: optionsFor(policy),
      );

      return accepted
          ? LocalAuthOutcome.success
          : LocalAuthOutcome.rejectedOrCancelled;
    } on PlatformException catch (error) {
      if (error.code == auth_error.notEnrolled) {
        return LocalAuthOutcome.notEnrolled;
      }
      if (error.code == auth_error.lockedOut ||
          error.code == auth_error.permanentlyLockedOut) {
        return LocalAuthOutcome.lockedOut;
      }
      if (error.code == auth_error.notAvailable) {
        return LocalAuthOutcome.unavailable;
      }
      return LocalAuthOutcome.failed;
    } finally {
      _authenticating = false;
    }
  }
}
```

`canOfferBiometric()` only decides whether the biometric feature should be offered in the UI at that moment. It must not change the saved preference.

`busy` prevents a double tap from opening two system prompts. I do not share one successful result between unrelated operations because authentication for login does not automatically authorize another sensitive action.

### Continue only after `success`

The call site does not infer behavior from an exception. It continues only when the adapter returns `success`:

```dart
Future<void> runQuickLogin() async {
  final outcome = await localAuth.authenticate(
    policy: LocalAuthPolicy.biometricPreferred,
    reason: 'Confirm to use quick login',
  );

  if (outcome != LocalAuthOutcome.success) {
    await handleLocalAuthOutcome(outcome);
    return;
  }

  final savedLogin = await loginVault.read();
  if (savedLogin == null) {
    await openNormalLogin();
    return;
  }

  await remoteSession.signIn(savedLogin);
}
```

This ordering is a control-flow gate:

```
local success
     │
     ▼
read the saved login information
     │
     ▼
server authentication
```

It does not mean that data in `loginVault` is cryptographically bound to biometrics. The server must still validate the login information, revocation state, and its own session policy.

### Do not destroy preferences because of transient failures

I keep the long-lived preference separate from runtime availability:

| Outcome               | Handling                                                                           |
| --------------------- | ---------------------------------------------------------------------------------- |
| `rejectedOrCancelled` | Stop this attempt; do not disable the feature or show a platform error.            |
| `notEnrolled`         | Explain the state, then let the user open Settings explicitly or use normal login. |
| `lockedOut`           | Do not loop the prompt; use the fallback allowed by the policy.                    |
| `unavailable`         | Preserve the preference and use another method for this attempt.                   |
| `busy`                | Ignore the repeated tap; do not open a second prompt.                              |
| `failed`              | Show a generic message and record only a filtered error code.                      |

When the user explicitly disables quick login, the app must disable the preference and remove the reusable data. That is a completely different state transition from cancel or lockout.

Similarly, if the server rejects the saved login information, the app must not turn local success into a session. The user needs to return to the normal login flow.

### Handle backgrounding and late prompt results

`stickyAuth: true` prevents the plugin from immediately returning failure when the system backgrounds the app during a prompt, such as when a phone call arrives. The plugin can try authentication again when the app resumes.

The trade-off is that a result may arrive after the user has left the screen. The call site therefore needs to check lifecycle state before applying a side effect:

```dart
final outcome = await localAuth.authenticate(
  policy: LocalAuthPolicy.biometricPreferred,
  reason: 'Confirm to continue',
);

if (!context.mounted || outcome != LocalAuthOutcome.success) return;

await continueCurrentFlow();
```

`context.mounted` only protects a widget that has been disposed. For an operation with a timeout, or one the user can cancel while the screen remains mounted, I also keep a request ID or cancellation state and check it before continuing.

### Android

`local_auth 2.2.0` requires the biometric permission in `AndroidManifest.xml`:

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <uses-permission android:name="android.permission.USE_BIOMETRIC" />

  <application>
    <!-- ... -->
  </application>
</manifest>
```

The host activity must extend `FlutterFragmentActivity`:

```kotlin
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity()
```

On Android 8 and earlier, the `LaunchTheme` parent must be a `Theme.AppCompat` theme to prevent the biometric dialog from crashing:

```xml
<resources>
  <style name="LaunchTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="android:windowBackground">@drawable/launch_background</item>
  </style>
</resources>
```

Before Android Q, `getAvailableBiometrics()` is limited when detecting biometric types other than fingerprint. I therefore use this list to adjust the UX, not as a general security decision based on one specific type.

### iOS

To use Face ID, the app needs `NSFaceIDUsageDescription` in `Info.plist`:

```xml
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to confirm before continuing.</string>
```

The text must accurately explain why the system prompt is appearing. Do not use one vague description if the app requests Face ID for several different actions.

The app does not receive face images, fingerprint templates, or raw biometric data. LocalAuthentication leaves that data to the operating system and security hardware; the app only configures a policy, supplies a reason, and receives a result.

With `biometricPreferred`, iOS may fall back to the passcode. If biometrics are mandatory, set `biometricOnly: true` and verify lockout and fallback behavior on a real device.

### Verify the result

The source I verified currently has tests for the persisted preference and displayed labels, but no direct test for the platform prompt, cancellation, lockout, `biometricOnly`, `stickyAuth`, or double taps. I therefore do not treat the dependency and native configuration as proof that the entire biometric flow is automated.

At minimum, the adapter above needs unit tests for the following cases:

1. `biometricPreferred` maps to `biometricOnly: false`.
2. `biometricsOnly` maps to `biometricOnly: true`.
3. A plugin result of `true` becomes `success`.
4. A plugin result of `false` becomes `rejectedOrCancelled`.
5. `notEnrolled`, `lockedOut`, `permanentlyLockedOut`, and `notAvailable` map correctly.
6. Two concurrent calls open only one prompt; the second call receives `busy`.
7. `_authenticating` resets after both success and exception.
8. Remote login is not called unless the local outcome is `success`.
9. Runtime unavailability does not change the saved preference.
10. Explicitly disabling the feature removes the reusable data.

After adding the test file, run the focused suite:

```bash
flutter test test/security/local_auth_gate_test.dart
```

The system prompt still needs a real-device test matrix:

| Case                               | Android              | iOS         | Expected result                                      |
| ---------------------------------- | -------------------- | ----------- | ---------------------------------------------------- |
| Biometrics enrolled                | Real device          | Real device | Run the operation only after success                 |
| User cancels                       | Yes                  | Yes         | Stop and preserve the preference                     |
| Nothing enrolled                   | Yes                  | Yes         | Show the correct guidance without looping the prompt |
| Lockout                            | Yes                  | Yes         | Do not disable the feature automatically             |
| Device credential fallback         | PIN/pattern/password | Passcode    | Appear only when the policy permits it               |
| Biometric-only                     | Yes                  | Yes         | Do not fall back to a device credential              |
| App backgrounds during prompt      | Yes                  | Yes         | Resume exactly one request                           |
| User leaves while prompt is active | Yes                  | Yes         | Do not run the old side effect                       |
| Enrollment changes                 | Yes                  | Yes         | Behavior matches the threat model                    |

Simulators and emulators are useful for the happy path, but they do not replace real-device testing for lockout, enrollment changes, and lifecycle behavior.

### Common mistakes and trade-offs

#### Calling every outcome “authentication failed”

User cancellation is not a platform failure. If a helper only returns `false`, the UI can easily show the same dialog for cancellation, missing enrollment, and lockout. I map each outcome group before choosing the UX.

#### Disabling the feature when availability returns false

The sensor may be temporarily unavailable or the user may be locked out. I preserve the preference and use another method for that attempt. The feature is disabled only when the user explicitly turns it off or an account policy requires cleanup.

#### Opening prompts from multiple widgets

A double tap or two call sites can create overlapping prompts. I keep the guard at the service boundary and return `busy` to the second request.

#### Treating `true` as proof that the server authenticated the user

`local_auth` evaluates a policy on the device only. Remote login can still fail because the saved data has expired or been revoked. I create a session only from the server result.

#### Claiming that biometrics protect the saved data

A Boolean is not a cryptographic binding. If the app needs a key that can be used only after biometric authentication, use platform access-control or cryptographic APIs and test enrollment changes, key invalidation, and the fallback policy.

#### Requiring biometric-only for every flow

This policy reduces recovery options when the sensor fails or reaches lockout. Conversely, allowing device credential fallback expands the set of people who can pass the local gate to anyone who knows the device unlock code. I select a policy based on the value of the operation rather than applying one policy to the entire app.

### Related article

Local authentication only decides when the app may continue. Reusable data still needs appropriate storage and a clear failure path. I keep Keychain and Keystore persistence in a separate article instead of repeating the storage implementation here.

{% content-ref url="/pages/Fbl8YWIUovHWvjb7NUlz" %}
[Secure Storage: Keychain and Keystore](/flutter/my-flutter/security-observability/secure-storage-keychain-keystore.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.
* `local_auth`: 2.2.0.
* `local_auth_android`: 1.0.56.
* `local_auth_darwin`: 1.6.1.

Version `3.x` of `local_auth` changed its API and error model. If you upgrade the package, recheck the options, exception type, native setup, and tests instead of copying this adapter unchanged.

### References

* [`local_auth 2.2.0`](https://pub.dev/packages/local_auth/versions/2.2.0)
* [Apple — Local Authentication](https://developer.apple.com/documentation/localauthentication)
* [Apple — Logging a User into Your App with Face ID or Touch ID](https://developer.apple.com/documentation/localauthentication/logging-a-user-into-your-app-with-face-id-or-touch-id)
* [Android — Show a biometric authentication dialog](https://developer.android.com/identity/sign-in/biometric-auth)
* [OWASP MASVS-AUTH-2 — Local authentication](https://mas.owasp.org/MASVS/controls/MASVS-AUTH-2/)
* [OWASP MASVS-AUTH-3 — Additional authentication](https://mas.owasp.org/MASVS/controls/MASVS-AUTH-3/)

## Conclusion

After separating these layers, I no longer treat biometric login as a button that returns `true` or `false`. The app must select an explicit policy, keep capability and enrollment separate from user preference, and continue the operation only after the system prompt succeeds.

`local_auth` provides a consistent system prompt on Android and iOS, but it does not replace server authentication or cryptographic access control. Device credential fallback is a deliberate choice in my quick-login flow; a higher-value operation needs a fresh review of both its policy and additional authentication.

This approach does not promise that biometric login cannot be bypassed. It places a clear local gate inside the stated threat model, prevents transient errors from destroying long-lived state, and makes cancellation, lockout, and fallback independently testable.

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