Authentication & Security

Biometric Authentication with local_auth

16 min Lesson 7 of 12

Biometric Authentication with local_auth

The local_auth package lets your Flutter app leverage the device's built-in hardware security — fingerprint sensors, Face ID, and device PIN/pattern — to authenticate the user without sending any credentials over the network. Authentication happens entirely on-device, making it both fast and privacy-friendly. This lesson covers setup, availability checks, triggering authentication, and combining biometrics with a securely stored token for an "app unlock" flow.

Adding the Dependency

Add local_auth to your pubspec.yaml:

dependencies:
  local_auth: ^2.3.0
  flutter_secure_storage: ^9.0.0  # for storing the session token

You also need platform-specific configuration:

  • Android: In android/app/src/main/AndroidManifest.xml, add <uses-permission android:name="android.permission.USE_BIOMETRIC" /> and set the activity theme to one that supports FlutterFragmentActivity (replace FlutterActivity with FlutterFragmentActivity in MainActivity.kt).
  • iOS: In ios/Runner/Info.plist, add the key NSFaceIDUsageDescription with a human-readable string explaining why you need Face ID access.
Note: On Android, local_auth requires FlutterFragmentActivity instead of the default FlutterActivity because the biometric dialog uses a Fragment internally. Forgetting this change is the single most common setup error.

Checking Biometric Availability

Before prompting the user, always verify three things: (1) the device supports biometrics at the hardware level, (2) the user has enrolled at least one biometric, and (3) the app can use biometric authentication. Use LocalAuthentication to query all three:

import 'package:local_auth/local_auth.dart';

class BiometricService {
  final LocalAuthentication _auth = LocalAuthentication();

  /// Returns true if the device supports biometrics AND
  /// the user has at least one biometric enrolled.
  Future<bool> isBiometricAvailable() async {
    final bool canCheck = await _auth.canCheckBiometrics;
    final bool isDeviceSupported = await _auth.isDeviceSupported();
    if (!canCheck || !isDeviceSupported) return false;

    final List<BiometricType> enrolled =
        await _auth.getAvailableBiometrics();
    return enrolled.isNotEmpty;
  }

  /// Returns the list of enrolled biometric types.
  Future<List<BiometricType>> getEnrolledBiometrics() async {
    return _auth.getAvailableBiometrics();
  }
}

BiometricType can be BiometricType.fingerprint, BiometricType.face, BiometricType.iris, or BiometricType.weak / BiometricType.strong. Inspecting this list lets you tailor the prompt text (e.g. "Use Face ID" vs "Use fingerprint").

Tip: canCheckBiometrics returns true even when no biometrics are enrolled — it only tests hardware presence. Always also call getAvailableBiometrics() and confirm the list is non-empty before showing a biometric prompt.

Authenticating the User

Call authenticate() with an AuthenticationOptions object to control fallback behaviour. The method returns true on success and false if the user cancels or fails too many times.

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

class BiometricService {
  final LocalAuthentication _auth = LocalAuthentication();

  Future<bool> authenticate({required String reason}) async {
    try {
      return await _auth.authenticate(
        localizedReason: reason,
        options: const AuthenticationOptions(
          biometricOnly: false,   // allow device PIN as fallback
          stickyAuth: true,       // keep dialog open if user leaves app
          sensitiveTransaction: true,
        ),
      );
    } on PlatformException catch (e) {
      if (e.code == auth_error.notAvailable ||
          e.code == auth_error.notEnrolled ||
          e.code == auth_error.lockedOut ||
          e.code == auth_error.permanentlyLockedOut) {
        // Surface a user-friendly message instead of crashing
        return false;
      }
      rethrow;
    }
  }

  Future<void> cancelAuthentication() async {
    await _auth.stopAuthentication();
  }
}
Warning: Always catch PlatformException when calling authenticate(). The error codes lockedOut and permanentlyLockedOut are thrown when the device disables biometrics after repeated failures — if you let these propagate uncaught your app will crash.

Combining Biometrics with a Secure Token (App Unlock Pattern)

A common production pattern is app unlock: the user logs in once with their full credentials (email + password), you store the resulting session token in flutter_secure_storage (hardware-backed on both platforms), and on subsequent launches you gate access to the token behind a biometric check. The network never sees the biometric data.

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class AppLockService {
  static const _tokenKey = 'session_token';
  final _storage = const FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );
  final _biometric = BiometricService();

  /// Called after a successful password login.
  Future<void> storeToken(String token) async {
    await _storage.write(key: _tokenKey, value: token);
  }

  /// Returns the stored token only after a successful biometric check.
  /// Returns null if biometrics fail or no token is stored.
  Future<String?> unlockWithBiometrics() async {
    final bool available = await _biometric.isBiometricAvailable();
    if (!available) return null;

    final bool authenticated = await _biometric.authenticate(
      reason: 'Verify your identity to access the app',
    );
    if (!authenticated) return null;

    return _storage.read(key: _tokenKey);
  }

  Future<void> logout() async {
    await _storage.delete(key: _tokenKey);
  }
}

Wiring It Into the UI

On the lock screen widget, call unlockWithBiometrics() on init and also expose a manual retry button. Use mounted guards after every await to prevent state updates on disposed widgets:

class LockScreen extends StatefulWidget {
  const LockScreen({super.key});
  @override
  State<LockScreen> createState() => _LockScreenState();
}

class _LockScreenState extends State<LockScreen> {
  final _lock = AppLockService();
  bool _checking = false;

  @override
  void initState() {
    super.initState();
    _tryUnlock();
  }

  Future<void> _tryUnlock() async {
    if (!mounted) return;
    setState(() => _checking = true);
    final token = await _lock.unlockWithBiometrics();
    if (!mounted) return;
    setState(() => _checking = false);
    if (token != null) {
      Navigator.pushReplacementNamed(context, '/home', arguments: token);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: _checking
            ? const CircularProgressIndicator()
            : Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  const Icon(Icons.fingerprint, size: 64),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: _tryUnlock,
                    child: const Text('Unlock with Biometrics'),
                  ),
                ],
              ),
      ),
    );
  }
}

Summary

Biometric authentication with local_auth follows a clear three-step flow: check availability (hardware + enrollment), authenticate (with graceful error handling for lockouts), and act on the result (in the app-unlock pattern, read the secure token). Key points:

  • Always verify both canCheckBiometrics and getAvailableBiometrics() before prompting.
  • Catch all PlatformException codes — especially lockedOut and permanentlyLockedOut.
  • Never store raw credentials on-device; store only the server-issued token inside flutter_secure_storage.
  • Use biometricOnly: false so users without enrolled biometrics can fall back to their device PIN.
  • Guard every post-await state update with if (!mounted) return.