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.02. 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_storagefor a complete secure login flowAdd 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.