So, in this article, I will be showing you how you can add biometric authentication — Face ID and fingerprint — to your Flutter app using the local_auth plugin.
This is one of the most common requests I get from mobile clients, and for good reason: a login screen with a fingerprint is expected in any finance, health, or messaging app today. I have shipped this exact flow into production apps, and it is simpler than most people expect — and it hides a handful of platform-specific traps that will silently break your build if you skip the setup steps.
For this purpose, we need to add these dependencies in your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
local_auth: ^2.2.0
flutter_secure_storage: ^9.2.0
-
local_authis the official plugin that talks to the platform's biometric APIs — Touch ID, Face ID, and Android's fingerprint and face unlock. -
flutter_secure_storagestores the auth token securely after the user authenticates, so you never keep credentials in plain text.
Let's jump into the coding part.
Step 1: Platform Setup (This Is Where Most Builds Break)
Before you write a single line of Dart, you must configure both platforms. Skip this and the plugin will throw at runtime, or in some cases at compile time.
Android. Add these permissions to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
For devices running Android 9 or lower you should also keep:
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
iOS. Face ID requires a usage description, otherwise iOS terminates your app when the prompt appears. Add this to ios/Runner/Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>Unlock your account using Face ID.</string>
That single missing key is the #1 cause of "my biometric app crashes on iPhone" reports. Xcode will not warn you; it will just kill the app at runtime.
Step 2: Check What the Device Actually Supports
You should never assume a device has biometrics. Emulators usually have none, some Android devices have none, and some users disable it in settings. Always check first, then fail gracefully.
import 'package:local_auth/local_auth.dart';
final LocalAuthentication _auth = LocalAuthentication();
Future<bool> canUseBiometrics() async {
bool isSupported = await _auth.isDeviceSupported();
bool hasEnrolled = await _auth.canCheckBiometrics;
return isSupported && hasEnrolled;
}
-
isDeviceSupported()returns true if the device has any biometric hardware. -
canCheckBiometricsreturns true if the user has actually enrolled a fingerprint or face. A device can support biometrics while the user has enrolled nothing — and your button should not light up in that case.
Step 3: Authenticate
Now the actual authentication. The key detail here is localizedReason: on iOS this is the sentence shown in the Face ID prompt, and on some Android versions it is required or the call fails.
Future<bool> authenticate() async {
bool authenticated = false;
try {
authenticated = await _auth.authenticate(
localizedReason: 'Authenticate to access your account',
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
),
);
} catch (e) {
// Platform errors, app switches, or user-cancelled.
return false;
}
return authenticated;
}
Two options worth understanding:
-
biometricOnly: falsemeans the user can also authenticate with their device passcode as a fallback when the fingerprint reader fails. For most apps you want this — a locked-out user is worse than a slightly less "pure" biometric login. -
stickyAuth: truekeeps the authentication valid if the app goes to the background mid-prompt. If you are running on iOS and the user is nudged into another app for a step, this prevents the flow from dying.
Step 4: Store the Session Token Securely
Biometric authentication proves the user is who they say they are, but you still need something to hold the session. Never keep it in SharedPreferences — store it in the secure enclave-backed storage:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
const FlutterSecureStorage _storage = FlutterSecureStorage();
Future<void> onBiometricSuccess(String token) async {
await _storage.write(key: 'auth_token', value: token);
}
Future<String?> getStoredToken() async {
return await _storage.read(key: 'auth_token');
}
The pattern that works in production: on first login ask for the password, get a token from your backend, and store it in secure storage. On every later launch, offer the biometric button — if the fingerprint or face matches, read the token from secure storage and restore the session without the user ever typing a password again.
Step 5: The Login Screen That Ties It Together
Here is the full pattern wired into a widget, the way I would actually ship it. A "Sign in with biometrics" button that only appears when the device can actually do it:
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
bool _hasBiometrics = false;
bool _busy = false;
@override
void initState() {
super.initState();
_loadCapability();
}
Future<void> _loadCapability() async {
final bool available = await canUseBiometrics();
if (!mounted) return;
setState(() => _hasBiometrics = available);
}
Future<void> _onBiometricLogin() async {
setState(() => _busy = true);
final bool ok = await authenticate();
if (!ok) {
if (mounted) {
setState(() => _busy = false);
// Show password fallback; do not show an error toast for a cancel.
}
return;
}
// Biometric match: restore the stored session token and enter the app.
final String? token = await getStoredToken();
if (mounted) {
setState(() => _busy = false);
if (token != null) {
Navigator.pushReplacement(context,
MaterialPageRoute(builder: (_) => const HomeScreen()));
} else {
// No stored token — full login required this time.
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign in')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_hasBiometrics && !_busy)
FilledButton.icon(
onPressed: _onBiometricLogin,
icon: const Icon(Icons.fingerprint),
label: const Text('Unlock with biometrics'),
)
else if (_busy)
const CircularProgressIndicator(),
TextButton(
onPressed: () {/* full password flow */},
child: const Text('Use password instead'),
),
],
),
),
);
}
}
Two details in this widget are worth copying, not retyping:
-
The button is conditional.
_hasBiometricsis loaded before the button is ever shown. A user on a device with no enrolled biometrics never sees a button that cannot work — that alone removes a whole class of "the fingerprint button does nothing" reviews. - The cancel case is not an error. A user who taps away from the Face ID prompt is back in the UI with the password path visible, not staring at a failure message. That is the difference between a flow users trust and one they abandon.
Important Notes
Here are the things that will bite you in production, in the order I have seen them:
Test on a real device. Biometrics do not work reliably on emulators and simulators. iOS Simulator can fake Face ID via Features → Face ID → Matching Enrolled Face, but Android emulators frequently report nothing enrolled. Your CI and your QA both need a physical device in the rotation, or you will ship a login flow you have never actually exercised.
Handle the cancel case explicitly. When the user taps "Cancel" in the Face ID dialog,
local_auththrowsPlatformExceptionwith codeNotAuthenticated. Swallow it and show your password fallback. What you must not do is show a generic error toast — a user who simply changed their mind is the most common event in the entire flow.biometricOnly: trueis rarely what you want. Without a passcode fallback, a user whose fingerprint reader has been blocked (iOS locks it after several failed attempts) is completely locked out of your app until they reboot and re-enter their passcode. On a finance app, that is a support ticket. Use the fallback.Biometrics can be revoked. If the user deletes a fingerprint or face after enrolling, the OS handles the prompt, but your stored session token is still valid. On a sensitive app, re-check
canCheckBiometricsbefore critical actions — not just at login — so an account whose device auth was removed still gets re-verified for high-value transactions.Do not use biometrics as your only security layer. The fingerprint proves the device holder's identity on that device; it does not prove much about anything else. Real apps pair it with a server-issued token that has its own expiry, and revoke that token server-side on suspicious activity. The client-side biometric is the front door, not the safe.
Enrollment can change while your app is backgrounded. If the user re-enrolls their fingerprints in Settings and returns to your app, iOS may consider the old authentication stale. If your app supports critical actions, re-run
authenticatefor those actions rather than trusting a login that happened an hour ago.Know your platform's error codes. Android 11+ and iOS both return specific errors for hardware-not-present, hardware-unavailable, and lockout states. Log them and map them to user messages — "Biometrics unavailable, use your passcode" is honest; "Unknown error" is not.
Alternative Approaches
If local_auth does not fit, two other roads exist:
- Package that wraps native SDKs — some enterprise clients want the bank-grade wrappers that vendor SDKs provide. They add native code, so you are now maintaining platform channels instead of just a plugin, and you should have a concrete compliance reason before choosing this path.
-
Platform channels for bespoke flows — when you need liveness detection or a custom enrollment UI that the plugin does not expose, you drop to native Kotlin/Swift and call back into Dart. This is real work, and you should only take it when a client's requirements genuinely exceed what
local_authoffers.
The default answer for 95% of apps is local_auth. It is maintained, null-safe, covers Face ID, Touch ID, and Android fingerprint and face unlock, and it keeps all the messy native code behind one clean Dart API.
A Quick FAQ
Does this work on the iOS Simulator? Only for Face ID, and only if you use the Features → Face ID → Matching Enrolled Face menu item. Android emulators generally report no enrolled biometrics, so assume the happy path only works on physical hardware.
Does Touch ID still need the usage description? No — NSFaceIDUsageDescription is required for Face ID specifically. But if you only support Touch ID and later add Face ID, iOS will crash at runtime until the key is added, so add it up front.
What if the user has no passcode set at all? Then biometrics cannot enroll, and canCheckBiometrics returns false. Your fallback button handles this automatically because it checks capability before rendering.
That's it — a complete biometric authentication flow in Flutter. Platform setup, capability checks, the authenticate call, a secure place for the session token, and a login screen that only offers biometrics to devices that can actually use them.
I have also written about secure storage, secure text fields, and app-locking patterns — comment below with your own use case and I'll cover it next.
*Gulshan Yad
Top comments (0)