All Posts
August 28, 202613 min read

Biometric Authentication in Flutter: Face ID and Fingerprint with local_auth

FlutterSecurityBiometricsAuthenticationiOSAndroid
Flutter biometric authentication showing Face ID and Fingerprint login flow on iOS and Android

Introduction

Biometric authentication - Face ID on iPhone, fingerprint on Android - has become a baseline expectation for any production mobile app. Users expect to unlock their banking app, rewards app, or any app with sensitive data using their face or fingerprint rather than typing a PIN every time.

But there is a common misconception among Flutter developers about how this works under the hood.

Flutter does not control Face ID or fingerprint scanning. Your Flutter app does not see the user's face data, does not process the fingerprint scan, and does not manage any biometric credentials. The operating system owns all of that. Your app simply asks the OS to authenticate the user and waits for a success or failure result.

This is a deliberate security design. Apple's Secure Enclave and Android's Trusted Execution Environment store and process all biometric data in isolated hardware. No app - not even a malicious one - can intercept or read that data.

What Flutter (and the local_auth package) does is provide a clean Dart API to initiate the OS-level authentication prompt and receive the result.

I have implemented this pattern in multiple production apps including BeesApp (Android) - where Face ID and fingerprint are used as the primary login method for returning users. This guide covers the exact implementation I use.


How Biometric Auth Works: The Full Flow

User taps "Login with Face ID / Fingerprint"
           |
           v
App checks: Does this device support biometrics?
           |
           v
App checks: What biometrics are enrolled? (Face ID / Fingerprint / Both)
           |
           v
App calls: local_auth.authenticate()
           |
           v
OS shows native biometric prompt (Face ID sheet / Fingerprint dialog)
           |
           v
User authenticates (scans face / touches sensor)
           |
           v
OS returns: success or failure to the app
           |
           v
On success: read stored auth token from flutter_secure_storage
           and proceed to the home screen

The app never sees biometric data. It only receives a boolean: authenticated or not.


Dependencies Setup

# pubspec.yaml
dependencies:
  local_auth: ^2.3.0
  flutter_secure_storage: ^9.2.2

iOS Configuration

Add usage description strings to ios/Runner/Info.plist:

<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to securely access your account</string>

No other iOS changes needed. local_auth handles Touch ID and Face ID automatically.

Android Configuration

Update android/app/src/main/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Required for biometric authentication -->
    <uses-permission android:name="android.permission.USE_BIOMETRIC" />

    <application ...>
        <activity
            android:name=".MainActivity"
            ...>
            <!-- Required: change FlutterActivity to FlutterFragmentActivity -->
        </activity>
    </application>
</manifest>

Update android/app/src/main/kotlin/.../MainActivity.kt:

// IMPORTANT: must extend FlutterFragmentActivity, NOT FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity()

This is a common gotcha - local_auth requires FlutterFragmentActivity on Android to show the biometric bottom sheet. Using FlutterActivity will cause a crash.


The BiometricAuthService

Create a dedicated service class that encapsulates all biometric logic. This keeps authentication concerns completely separate from your UI:

// lib/core/services/biometric/biometric_auth_service.dart
import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_error;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter/services.dart';

class BiometricAuthService {
  final LocalAuthentication _localAuth = LocalAuthentication();

  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );

  static const _biometricEnabledKey = 'biometric_enabled';
  static const _userTokenKey = 'user_token';

  /// Check if this device supports biometric authentication at all
  Future<bool> isDeviceSupported() async {
    return await _localAuth.isDeviceSupported();
  }

  /// Check if biometrics are enrolled on this device
  /// (user has set up Face ID or fingerprint in Settings)
  Future<bool> canCheckBiometrics() async {
    return await _localAuth.canCheckBiometrics;
  }

  /// Get the list of available biometric types on this device
  Future<List<BiometricType>> getAvailableBiometrics() async {
    return await _localAuth.getAvailableBiometrics();
  }

  /// Check if this device has Face ID specifically
  Future<bool> hasFaceId() async {
    final biometrics = await getAvailableBiometrics();
    return biometrics.contains(BiometricType.face);
  }

  /// Check if this device has fingerprint/Touch ID
  Future<bool> hasFingerprint() async {
    final biometrics = await getAvailableBiometrics();
    return biometrics.contains(BiometricType.fingerprint);
  }

  /// Check if user has enabled biometric login in this app's settings
  Future<bool> isBiometricEnabled() async {
    final value = await _storage.read(key: _biometricEnabledKey);
    return value == 'true';
  }

  /// Save user's preference to use biometric login
  Future<void> setBiometricEnabled(bool enabled) async {
    await _storage.write(
      key: _biometricEnabledKey,
      value: enabled.toString(),
    );
  }

  /// Authenticate using biometrics.
  /// Returns [BiometricResult] with success status and error info.
  Future<BiometricResult> authenticate({
    String reason = 'Verify your identity to continue',
  }) async {
    try {
      final isSupported = await isDeviceSupported();
      if (!isSupported) {
        return BiometricResult.failure(
          reason: BiometricFailureReason.notSupported,
          message: 'This device does not support biometric authentication.',
        );
      }

      final canCheck = await canCheckBiometrics();
      if (!canCheck) {
        return BiometricResult.failure(
          reason: BiometricFailureReason.notEnrolled,
          message: 'No biometrics enrolled. Please set up Face ID or fingerprint in Settings.',
        );
      }

      final authenticated = await _localAuth.authenticate(
        localizedReason: reason,
        options: const AuthenticationOptions(
          stickyAuth: true,    // Keep prompt open if user switches apps
          biometricOnly: true, // Don't fall back to PIN/password
          useErrorDialogs: true,
        ),
      );

      if (authenticated) {
        return BiometricResult.success();
      }

      return BiometricResult.failure(
        reason: BiometricFailureReason.failed,
        message: 'Authentication failed. Please try again.',
      );
    } on PlatformException catch (e) {
      return _handlePlatformException(e);
    } catch (e) {
      return BiometricResult.failure(
        reason: BiometricFailureReason.unknown,
        message: 'An unexpected error occurred.',
      );
    }
  }

  BiometricResult _handlePlatformException(PlatformException e) {
    switch (e.code) {
      case auth_error.notAvailable:
        return BiometricResult.failure(
          reason: BiometricFailureReason.notSupported,
          message: 'Biometric authentication is not available on this device.',
        );
      case auth_error.notEnrolled:
        return BiometricResult.failure(
          reason: BiometricFailureReason.notEnrolled,
          message: 'No biometrics enrolled. Please set up Face ID or fingerprint in Settings.',
        );
      case auth_error.lockedOut:
        return BiometricResult.failure(
          reason: BiometricFailureReason.lockedOut,
          message: 'Too many failed attempts. Biometrics are temporarily locked.',
        );
      case auth_error.permanentlyLockedOut:
        return BiometricResult.failure(
          reason: BiometricFailureReason.permanentlyLockedOut,
          message: 'Biometrics are locked. Please use your device passcode to unlock.',
        );
      default:
        return BiometricResult.failure(
          reason: BiometricFailureReason.unknown,
          message: e.message ?? 'Authentication failed.',
        );
    }
  }

  /// Store the user's auth token securely for biometric login
  Future<void> storeTokenForBiometricLogin(String token) async {
    await _storage.write(key: _userTokenKey, value: token);
  }

  /// Retrieve the stored auth token after successful biometric authentication
  Future<String?> getStoredToken() async {
    return await _storage.read(key: _userTokenKey);
  }

  /// Clear all stored biometric data (on logout)
  Future<void> clearBiometricData() async {
    await _storage.deleteAll();
  }
}

// Result type for clean error handling
class BiometricResult {
  final bool isSuccess;
  final BiometricFailureReason? failureReason;
  final String? errorMessage;

  BiometricResult._({
    required this.isSuccess,
    this.failureReason,
    this.errorMessage,
  });

  factory BiometricResult.success() => BiometricResult._(isSuccess: true);

  factory BiometricResult.failure({
    required BiometricFailureReason reason,
    required String message,
  }) => BiometricResult._(
    isSuccess: false,
    failureReason: reason,
    errorMessage: message,
  );
}

enum BiometricFailureReason {
  notSupported,
  notEnrolled,
  failed,
  lockedOut,
  permanentlyLockedOut,
  unknown,
}

Integrating Biometric Auth into Your Login Flow

Step 1: After a Successful Password Login, Offer to Enable Biometrics

// lib/features/auth/presentation/providers/auth_provider.dart
class AuthProvider extends ChangeNotifier {
  final BiometricAuthService _biometricService = BiometricAuthService();

  Future<void> loginWithPassword(String email, String password) async {
    // ... perform normal login
    final token = await _apiClient.login(email, password);

    // Store token securely for future biometric logins
    await _biometricService.storeTokenForBiometricLogin(token);

    // Prompt user to enable biometric for next time
    final canUseBiometrics = await _biometricService.canCheckBiometrics();
    if (canUseBiometrics) {
      _shouldPromptBiometricSetup = true;
      notifyListeners();
    }
  }
}

Step 2: Biometric Login on Returning Users

Future<void> loginWithBiometrics() async {
  final biometricService = BiometricAuthService();

  // Check if user has enabled biometric login
  final isEnabled = await biometricService.isBiometricEnabled();
  if (!isEnabled) return;

  // Authenticate
  final result = await biometricService.authenticate(
    reason: 'Login to your account',
  );

  if (result.isSuccess) {
    // Retrieve the stored token
    final token = await biometricService.getStoredToken();
    if (token != null) {
      // Use token to restore authenticated session
      await _sessionService.restoreSession(token);
      _navigateToHome();
    }
  } else {
    // Handle specific failure reasons
    switch (result.failureReason) {
      case BiometricFailureReason.notEnrolled:
        _showMessage('Please set up Face ID or Fingerprint in Settings.');
        break;
      case BiometricFailureReason.lockedOut:
        _showMessage('Too many attempts. Please try again later.');
        break;
      default:
        _showMessage(result.errorMessage ?? 'Authentication failed.');
    }
  }
}

Biometric Setup Toggle UI

class BiometricSettingsTile extends StatefulWidget {
  const BiometricSettingsTile({super.key});

  @override
  State<BiometricSettingsTile> createState() => _BiometricSettingsTileState();
}

class _BiometricSettingsTileState extends State<BiometricSettingsTile> {
  final _service = BiometricAuthService();
  bool _isEnabled = false;
  bool _isSupported = false;
  String _biometricLabel = 'Biometric Login';

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

  Future<void> _loadState() async {
    final supported = await _service.canCheckBiometrics();
    final enabled = await _service.isBiometricEnabled();
    final hasFace = await _service.hasFaceId();

    setState(() {
      _isSupported = supported;
      _isEnabled = enabled;
      _biometricLabel = hasFace ? 'Face ID Login' : 'Fingerprint Login';
    });
  }

  Future<void> _toggleBiometric(bool value) async {
    if (value) {
      // Verify biometrics before enabling to confirm device supports it
      final result = await _service.authenticate(
        reason: 'Verify your identity to enable biometric login',
      );
      if (!result.isSuccess) return;
    }

    await _service.setBiometricEnabled(value);
    setState(() => _isEnabled = value);
  }

  @override
  Widget build(BuildContext context) {
    if (!_isSupported) return const SizedBox.shrink();

    return SwitchListTile(
      title: Text(_biometricLabel),
      subtitle: const Text('Use biometrics to log in quickly and securely'),
      value: _isEnabled,
      onChanged: _toggleBiometric,
      secondary: const Icon(Icons.fingerprint),
    );
  }
}

Auto-Trigger Biometrics on App Launch

A polished UX triggers biometric authentication automatically when a returning user opens the app, rather than showing a login screen with an extra button to tap:

// lib/app/screens/splash_screen.dart
class SplashScreen extends StatefulWidget {
  const SplashScreen({super.key});

  @override
  State<SplashScreen> createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen> {
  final _biometricService = BiometricAuthService();

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

  Future<void> _handleAuth() async {
    await Future.delayed(const Duration(milliseconds: 500)); // Let splash render

    final biometricEnabled = await _biometricService.isBiometricEnabled();
    final storedToken = await _biometricService.getStoredToken();

    if (biometricEnabled && storedToken != null) {
      // Returning user with biometrics enabled - authenticate immediately
      final result = await _biometricService.authenticate(
        reason: 'Login to your account',
      );

      if (result.isSuccess && mounted) {
        Navigator.pushReplacement(context, MaterialPageRoute(
          builder: (_) => const HomeScreen(),
        ));
        return;
      }
    }

    // No biometrics or auth failed - go to login screen
    if (mounted) {
      Navigator.pushReplacement(context, MaterialPageRoute(
        builder: (_) => const LoginScreen(),
      ));
    }
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: CircularProgressIndicator()),
    );
  }
}

Security Best Practices

Do store in flutter_secure_storage:

  • Auth tokens for biometric session restoration
  • The user's biometric preference flag

Do not store:

  • Passwords (ever - not even encrypted)
  • Biometric data (the OS handles this - your app never sees it)
  • Sensitive user PII in shared preferences or SQLite without encryption

Always:

  • Clear stored biometric data on logout (biometricService.clearBiometricData())
  • Handle lockedOut and permanentlyLockedOut states - these require the user to use their device PIN
  • Test on a real device - biometrics do not work in simulators/emulators reliably

iOS vs Android: Key Differences

FeatureiOSAndroid
Face biometricFace ID (Secure Enclave)Face unlock (varies by manufacturer)
Touch biometricTouch IDFingerprint sensor
Prompt UINative iOS sheetMaterial bottom sheet
Required setupNSFaceIDUsageDescription in Info.plistUSE_BIOMETRIC permission in Manifest
Required base classAny FlutterActivityFlutterFragmentActivity
Fallback to passcodeControlled by biometricOnly optionControlled by biometricOnly option

Conclusion

Biometric authentication in Flutter is simpler than most developers expect, once you understand the key insight: your app delegates everything to the OS. You do not process Face ID. You do not read fingerprint data. You ask, the OS authenticates, and you receive a result.

The local_auth package wraps that OS call cleanly, and flutter_secure_storage gives you an encrypted place to store the user token that gets retrieved after a successful biometric prompt.

The full flow - check support, check enrollment, authenticate, retrieve token, restore session - is production-tested in apps like BeesApp where Face ID is used as the primary login method for returning users. It is fast, it is secure by design, and users love it.

The most important things to get right:

  • Use FlutterFragmentActivity on Android (the most common gotcha)
  • Never store passwords - only store tokens for session restoration
  • Handle all failure states including locked out
  • Clear everything on logout

Need to implement secure biometrics or user authentication in your app? I build secure authentication systems with Face ID, fingerprint scanning, and encrypted secure storage integrations for high-security applications. Book a meeting to secure your mobile app.


Written by Moeen Ahmad, Senior Software Engineer working across mobile apps, backend systems, cloud deployments, and AI-powered products. I write about practical engineering, real project lessons, and building software that actually ships.

Share

Interested in working together?

Let's discuss your project and explore how I can help bring it to life.