Biometric Authentication with local_auth
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 supportsFlutterFragmentActivity(replaceFlutterActivitywithFlutterFragmentActivityinMainActivity.kt). - iOS: In
ios/Runner/Info.plist, add the keyNSFaceIDUsageDescriptionwith a human-readable string explaining why you need Face ID access.
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").
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();
}
}
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
canCheckBiometricsandgetAvailableBiometrics()before prompting. - Catch all
PlatformExceptioncodes — especiallylockedOutandpermanentlyLockedOut. - Never store raw credentials on-device; store only the server-issued token inside
flutter_secure_storage. - Use
biometricOnly: falseso users without enrolled biometrics can fall back to their device PIN. - Guard every post-
awaitstate update withif (!mounted) return.