Skip to content
Blog

Biometric Authentication in Flutter: A Complete Guide to local_auth

Add fingerprint, Face ID, and passcode authentication to your Flutter app with the local_auth package — setup, capabilities, exception handling, and production best practices.

Published on • October 28, 2026

AI Assistant

Biometric unlock is one of those features users expect by default in 2026: fingerprint on Android, Face ID on iOS and macOS, Windows Hello on desktop. The local_auth package — published and maintained by the Flutter team itself as a verified flutter.dev package — is the standard way to bring this to a Flutter app.

What local_auth Provides

local_auth performs local, on-device authentication. On supported devices this includes biometrics such as fingerprint or facial recognition, with fallback to the device’s secure lock (PIN, pattern, or passcode).

Current platform support:

AndroidiOSmacOSWindows
SupportSDK 24+13.0+10.15+Windows 10+

The package is a federated plugin: local_auth_android, local_auth_darwin, and local_auth_windows implement the platform-specific behavior behind a shared interface, so each platform gets native-quality handling while you program against one Dart API.

Installation

dependencies:
  flutter:
    sdk: flutter
  local_auth: ^3.0.2

Platform setup differs, and skipping it is the number-one source of “it returns false” reports:

  • Android: follow the local_auth_android README — you’ll add the USE_BIOMETRIC permission, and for older devices the USE_FINGERPRINT permission. On Android 15+ (API 35) be aware of foreground service requirements if your authentication flow interacts with backgrounded activity.
  • iOS/macOS: follow the local_auth_darwin README — add NSFaceIDUsageDescription to Info.plist if you use Face ID, and enable the Keychain entitlement in Xcode.

Checking Device Capabilities

Before prompting, ask what the device can do:

import 'package:local_auth/local_auth.dart';

final LocalAuthentication auth = LocalAuthentication();

final bool canAuthenticateWithBiometrics = await auth.canCheckBiometrics;
final bool canAuthenticate =
    canAuthenticateWithBiometrics || await auth.isDeviceSupported();

Two distinct questions here:

  • canCheckBiometrics — is biometric hardware available?
  • isDeviceSupported() — does the device support any local authentication (including PIN/passcode fallback)?

canCheckBiometrics alone doesn’t tell you whether the user has actually enrolled a biometric. For that:

final List<BiometricType> availableBiometrics = await auth.getAvailableBiometrics();

if (availableBiometrics.isNotEmpty) {
  // Some biometrics are enrolled.
}

Available types include BiometricType.face, BiometricType.fingerprint, BiometricType.weak, and BiometricType.strong. The docs advise against depending on specific types — they’re device- and platform-specific, and new ones may be added. The robust approach: check only that some biometric is enrolled.

Authenticating

The core call:

final bool didAuthenticate = await auth.authenticate(
  localizedReason: 'Please authenticate to show account balance',
);

localizedReason is required — it’s the message shown in the system dialog explaining why you’re asking. Write it as a real reason; “Please authenticate” tells the user nothing.

Requiring Biometrics Only

By default, authenticate() prefers biometrics but allows the user to fall back to PIN, pattern, or passcode. To require biometrics strictly:

final bool didAuthenticate = await auth.authenticate(
  localizedReason: 'Confirm it is you',
  biometricOnly: true,
);

Note: biometricOnly is not supported on Windows, because Windows Hello’s underlying API doesn’t allow selecting the authentication method.

Surviving Backgrounding

On mobile, the system can cancel authentication if the app is backgrounded — a phone call arriving mid-Face-ID, for example. Setting persistAcrossBackgrounding: true makes the plugin wait for the app to return to the foreground, retry authentication, and return only when that new attempt completes:

final bool didAuthenticate = await auth.authenticate(
  localizedReason: 'Sign in to continue',
  persistAcrossBackgrounding: true,
);

Customizing Dialog Messages

The system dialog’s copy is platform-specific and localizable via AuthMessages. To customize Android and iOS, depend on the platform implementation packages directly:

import 'package:local_auth_android/local_auth_android.dart';
import 'package:local_auth_darwin/local_auth_darwin.dart';

final bool didAuthenticate = await auth.authenticate(
  localizedReason: 'Please authenticate to show account balance',
  authMessages: const <AuthMessages>[
    AndroidAuthMessages(
      signInTitle: 'Oops! Biometric authentication required!',
      cancelButton: 'No thanks',
    ),
    IOSAuthMessages(cancelButton: 'No thanks'),
  ],
);

Each platform class exposes its own set of customizable strings — go to the platform package docs for the full list.

Handling Exceptions

authenticate() throws LocalAuthException in most failure cases. The LocalAuthExceptionCodes enum exposes known error codes for targeted handling:

try {
  final bool didAuthenticate = await auth.authenticate(
    localizedReason: 'Please authenticate to show account balance',
  );
  // ... proceed on success
} on LocalAuthException catch (e) {
  if (e.code == LocalAuthExceptionCode.noBiometricHardware) {
    // No hardware — offer passcode fallback or hide the option
  } else if (e.code == LocalAuthExceptionCode.temporaryLockout ||
      e.code == LocalAuthExceptionCode.biometricLockout) {
    // Too many failed attempts — inform the user, suggest device unlock
  } else {
    // Other known failures
  }
}

The lockout codes are worth special attention: after repeated failures, Android and iOS lock biometric attempts and require the device credential. Good UX acknowledges this rather than silently failing.

Architecture Best Practices

A few patterns that hold up in production:

1. Gate on capability, not assumption

Don’t show a “Enable Face ID” toggle to users whose devices can’t do it:

Future<bool> biometricsAvailable() async {
  try {
    return await auth.canCheckBiometrics || await auth.isDeviceSupported();
  } on PlatformException {
    return false;
  }
}

2. Use biometrics for app unlock, not as sole auth

Biometrics gate access to an already-authenticated session. The account itself should be tied to server-side credentials (or federated identity); biometrics protect the local session. This keeps you safe when a device is enrolled with someone else’s face or when the OS lowers biometric strictness.

3. Handle the “not enrolled” path

canCheckBiometrics returning true says hardware exists, not that the user set it up. Offer a path to system settings (via a settings-launching package) when getAvailableBiometrics() returns empty but the user has toggled your in-app biometric setting on.

4. Remember state deliberately

Whether “stay unlocked for 15 minutes” or “lock when app backgrounds” is a product decision. WidgetsBindingObserver.didChangeAppLifecycleState pairs naturally with biometric unlock: re-prompt on paused → resumed if your policy demands it.

Testing

Biometrics are awkward to test. Options:

  • Physical devices — the only truly faithful test. Enroll and remove biometrics, test lockout by failing repeatedly.
  • Simulators/emulators — iOS Simulator supports enrolled Face ID via Features menu; Android emulators have virtual test biometrics under settings.
  • Mock the plugin — inject a fake LocalAuthentication in widget/unit tests. Because the plugin is a plain class, wrapping it in a small interface makes it trivially mockable:
abstract class BiometricGateway {
  Future<bool> authenticate(String reason);
}

class LocalBiometricGateway implements BiometricGateway {
  final LocalAuthentication _auth = LocalAuthentication();
  @override
  Future<bool> authenticate(String reason) =>
      _auth.authenticate(localizedReason: reason);
}

Your cubit/bloc then depends on BiometricGateway, and tests never touch real hardware.

Complete Flow Example

class AppLockService {
  final LocalAuthentication _auth = LocalAuthentication();
  bool _unlocked = false;

  Future<bool> get canUseBiometrics async =>
      await _auth.canCheckBiometrics || await _auth.isDeviceSupported();

  Future<bool> unlock() async {
    if (_unlocked) return true;
    try {
      _unlocked = await _auth.authenticate(
        localizedReason: 'Unlock RedLineSoft Blog',
        persistAcrossBackgrounding: true,
      );
      return _unlocked;
    } on LocalAuthException catch (e) {
      if (e.code == LocalAuthExceptionCode.temporaryLockout) {
        // Surface "too many attempts — unlock your device first"
      }
      return false;
    }
  }

  void lock() => _unlocked = false;
}

Wire lock() into your lifecycle observer and unlock() into your app’s root gate, and you have a complete app-lock flow.

Conclusion

local_auth gives Flutter apps a first-party, federated path to on-device authentication across Android, iOS, macOS, and Windows. The API is small — canCheckBiometrics, getAvailableBiometrics, authenticate — but the details matter: capability checks before prompting, biometricOnly versus fallback, persistAcrossBackgrounding for real-world interruptions, targeted handling of lockout codes, and biometrics as session protection rather than sole authentication. Handle those, and biometric unlock feels invisible — exactly as it should.

Further Reading