Flutter Biometric Authentication — Face ID & Fingerprint Guide

Bilal Fali··4 min read
Flutter Biometric Authentication — Face ID & Fingerprint Guide

Flutter Biometric Authentication — Face ID & Fingerprint Guide

Prerequisites

Before diving in, make sure you have:

  • Flutter 3.x installed

  • A physical device (biometrics don't work on most emulators)

  • Basic knowledge of Flutter and async/await

  • Android minSdkVersion 23+ / iOS 9.0+


Why Biometric Authentication?

Passwords are friction. Every time a user has to type their credentials, you risk them abandoning your app. Biometric authentication — Face ID, fingerprint, iris scan — gives users a fast, secure, and familiar way to verify their identity.

In Flutter, the local_auth package wraps the platform's native biometric APIs into a clean Dart interface. One package. Android and iOS. Done.

By the end of this guide, you'll have:

  • Biometric login working on Android (fingerprint) and iOS (Face ID / Touch ID)

  • Proper error handling for every edge case

  • A PIN/password fallback when biometrics aren't available


Setup

1. Add the dependency

yaml

dependencies:
  local_auth: ^2.3.0

2. Android configuration

AndroidManifest.xml:

xml

<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>

build.gradle:

groovy

android {
    defaultConfig {
        minSdkVersion 23
    }
}

MainActivity.kt:

kotlin

import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()

3. iOS configuration

Info.plist:

xml

<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to securely authenticate you.</string>

Check Device Support

dart

import 'package:local_auth/local_auth.dart';

class BiometricService {
  final LocalAuthentication _auth = LocalAuthentication();

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

  Future<List<BiometricType>> getAvailableBiometrics() async {
    if (!await canCheckBiometrics()) return [];
    return await _auth.getAvailableBiometrics();
  }
}

Authenticate — Core Implementation

dart

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<BiometricResult> authenticate() async {
    try {
      final bool didAuthenticate = await _auth.authenticate(
        localizedReason: 'Authenticate to access your account',
        options: const AuthenticationOptions(
          biometricOnly: false,
          stickyAuth: true,
          sensitiveTransaction: false,
        ),
      );
      return didAuthenticate ? BiometricResult.success : BiometricResult.failure;
    } on PlatformException catch (e) {
      return _mapError(e);
    }
  }

  BiometricResult _mapError(PlatformException e) {
    switch (e.code) {
      case auth_error.notEnrolled:          return BiometricResult.notEnrolled;
      case auth_error.lockedOut:            return BiometricResult.lockedOut;
      case auth_error.permanentlyLockedOut: return BiometricResult.permanentlyLockedOut;
      case auth_error.notAvailable:         return BiometricResult.notAvailable;
      case auth_error.passcodeNotSet:       return BiometricResult.passcodeNotSet;
      default:                              return BiometricResult.failure;
    }
  }
}

enum BiometricResult {
  success, failure, notEnrolled,
  lockedOut, permanentlyLockedOut,
  notAvailable, passcodeNotSet,
}

Handle Every Edge Case

dart

Future<void> handleAuth(BuildContext context) async {
  final service = BiometricService();

  if (!await service.isDeviceSupported()) {
    _show(context, 'This device does not support biometrics.');
    return;
  }

  switch (await service.authenticate()) {
    case BiometricResult.success:
      Navigator.pushReplacementNamed(context, '/home');
    case BiometricResult.notEnrolled:
      _show(context, 'No biometrics enrolled. Set up fingerprint or Face ID in Settings.');
    case BiometricResult.lockedOut:
      _show(context, 'Too many attempts. Try again in 30 seconds.');
    case BiometricResult.permanentlyLockedOut:
      _show(context, 'Biometrics disabled. Use your PIN to unlock.');
    case BiometricResult.passcodeNotSet:
      _show(context, 'Set up a screen lock (PIN/pattern) to use biometrics.');
    default:
      _show(context, 'Authentication failed. Try again.');
  }
}

void _show(BuildContext context, String msg) =>
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
  );

Full UI Example

dart

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

class _State extends State<BiometricLoginScreen> {
  final _service = BiometricService();
  bool _loading = false;
  List<BiometricType> _available = [];

  @override
  void initState() {
    super.initState();
    _service.getAvailableBiometrics().then((v) => setState(() => _available = v));
  }

  IconData get _icon => _available.contains(BiometricType.face)
      ? Icons.face_rounded
      : _available.contains(BiometricType.fingerprint)
          ? Icons.fingerprint
          : Icons.lock_rounded;

  String get _label => _available.contains(BiometricType.face)
      ? 'Sign in with Face ID'
      : _available.contains(BiometricType.fingerprint)
          ? 'Sign in with Fingerprint'
          : 'Sign in with PIN';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(mainAxisSize: MainAxisSize.min, children: [
          Icon(_icon, size: 72, color: Theme.of(context).colorScheme.primary),
          const SizedBox(height: 24),
          Text('Welcome back', style: Theme.of(context).textTheme.headlineSmall),
          const SizedBox(height: 8),
          Text('Verify your identity to continue'),
          const SizedBox(height: 40),
          if (_loading)
            const CircularProgressIndicator()
          else
            FilledButton.icon(
              onPressed: _available.isNotEmpty ? () async {
                setState(() => _loading = true);
                await handleAuth(context);
                if (mounted) setState(() => _loading = false);
              } : null,
              icon: Icon(_icon),
              label: Text(_label),
            ),
          if (_available.isEmpty) ...[
            const SizedBox(height: 12),
            TextButton(
              onPressed: () => Navigator.pushNamed(context, '/pin-login'),
              child: const Text('Use PIN instead'),
            ),
          ],
        ]),
      ),
    );
  }
}

Security Best Practices

1. Never read sensitive data before authentication

dart

// ❌ Wrong
final token = await storage.read(key: 'auth_token');
await authenticate();

// ✅ Correct
if (await authenticate()) {
  final token = await storage.read(key: 'auth_token');
}

2. Use flutter_secure_storage — never SharedPreferences

dart

final storage = const FlutterSecureStorage();
await storage.write(key: 'auth_token', value: token);

3. For payments: sensitiveTransaction: true + biometricOnly: true

dart

await _auth.authenticate(
  localizedReason: 'Confirm payment of \$50.00',
  options: const AuthenticationOptions(
    sensitiveTransaction: true,
    biometricOnly: true,
  ),
);

4. Cancel on dispose

dart

@override
void dispose() {
  _auth.stopAuthentication();
  super.dispose();
}

Testing

Emulator: Android Extended Controls → Fingerprint. iOS Simulator → Hardware → Touch ID.

Real device — test these scenarios:

  • Biometrics not enrolled (first time)

  • 5 failed attempts → lockout

  • App goes to background mid-auth

  • No screen lock set


Conclusion

The difference between a buggy biometric implementation and a production-ready one is entirely in the edge cases. Handle notEnrolled, lockedOut, permanentlyLockedOut, and passcodeNotSet — and your users will never hit a dead end.

What's next?

  • Combine with flutter_secure_storage for a complete secure login flow

  • Add biometric re-auth for sensitive actions (payments, settings)

  • Google Play Billing in Flutter if you're building a premium app


Published on bidev.site — Flutter tutorials from production experience.

Share

Comments

Comments

Leave a comment

0/2000

Comments appear after review.