Flutter Biometric Auth With local_auth
HerCycle, a privacy-first period tracker I built, stores everything on the device: cycle logs, symptom history, health reports. There is no account, no registration, and no server sync. That privacy promise only holds if
HerCycle, a privacy-first period tracker I built, stores everything on the device: cycle logs, symptom history, health reports. There is no account, no registration, and no server sync. That privacy promise only holds if the phone itself cannot be picked up and read by anyone else, which is why the app ships a PIN and biometric app lock. The lock is the feature that makes the rest of the architecture believable.
Adding fingerprint and face unlock to a Flutter app takes one package: local_auth\, the official plugin maintained by the Flutter team. It is a small integration, but it has sharp edges: platform setup that silently fails, devices with no biometrics enrolled, users locked out after failed attempts, and the temptation to treat a biometric prompt as a login. This guide covers the full pattern that ships in production, from setup to failure handling to the privacy rules that keep users trusting the app.
What local_auth actually does
local_auth\ asks the operating system to verify the person holding the phone. On Android that means fingerprint or face unlock; on iOS it means Touch ID or Face ID. The plugin returns a boolean: authenticated or not. That is all it does, and that limitation is the point.
Biometric matching happens inside the secure enclave on iOS and the Trusted Execution Environment on Android. Your Dart code never sees a fingerprint image, a face template, or any biometric data at all, and nothing biometric ever leaves the device. The plugin also does not encrypt your data and does not identify who the user is. It answers one question: is the person holding this phone its owner? Use it to guard access to data that is already on the device, not as a replacement for sign-in. For that, pair it with a real authentication flow such as the one in Flutter Firebase Authentication: Email, Google and Phone.
The current version is local_auth\ 3.0.2. Version 3 changed the API in one way that breaks older tutorials: biometricOnly\ moved out of the AuthenticationOptions\ object and became a direct parameter of authenticate()\. If you copy a snippet that passes options: const AuthenticationOptions(...)\, it will not compile against version 3.
Step 1: Add the package
Add the dependency and fetch it:
\yaml
dependencies:
flutter:
sdk: flutter
local_auth: ^3.0.2
\\
\bash
flutter pub get
\\
local_auth\ is a federated plugin. The shared local_auth\ package defines the API, while local_auth_android\, local_auth_darwin\, and local_auth_windows\ implement it per platform. You only depend on the top-level package; the platform implementations resolve automatically. Android requires SDK 24 or newer and iOS requires version 13 or newer.
Step 2: Platform setup
Most local_auth\ failures are setup failures, and they fail silently by returning false\. Get this step right before writing any Dart.
On Android, add the biometric permission to android/app/src/main/AndroidManifest.xml\, outside the <application>\ tag:
\xml
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
\\
USE_BIOMETRIC\ covers modern devices. USE_FINGERPRINT\ is still needed for older devices that only expose the legacy fingerprint API. The plugin also needs the activity to be a FragmentActivity\. Modern Flutter templates already use one, but if your MainActivity\ extends the plain FlutterActivity\ from an old template, switch it to FlutterFragmentActivity\ or the prompt will never appear.
On iOS, add a usage description to ios/Runner/Info.plist\. Without it, the Face ID prompt fails:
\xml
<key>NSFaceIDUsageDescription</key>
<string>Authenticate with Face ID to unlock your private health data.</string>
\\
Write the sentence for the user, not for Apple. It appears in the system prompt, so it should say what is being protected and why. A vague string like "We need Face ID" erodes trust in exactly the moment you need it most.
Step 3: Check before you prompt
Never show a fingerprint button without asking the device first. Three different checks answer three different questions:
\`dart
import 'package:local_auth/local_auth.dart';
final LocalAuthentication auth = LocalAuthentication();
Future canUseBiometrics() async {
final bool hasHardware = await auth.canCheckBiometrics;
final bool deviceSupported = await auth.isDeviceSupported();
final List available =
await auth.getAvailableBiometrics();
return hasHardware && deviceSupported && available.isNotEmpty;
}
`\
canCheckBiometrics\ reports whether biometric hardware exists. isDeviceSupported()\ reports whether any device-level authentication is available at all, including PIN and passcode. getAvailableBiometrics()\ lists the biometrics the user has actually enrolled, such as BiometricType.face\ or BiometricType.fingerprint\. A phone can have a fingerprint sensor and still return an empty list because the owner never enrolled a finger. That is the most common reason a biometric button does nothing: the code assumed hardware means enrolled.
Hide the biometric option entirely when this check returns false\, and offer your fallback instead. A disabled button with no explanation is a support ticket waiting to happen.
Step 4: Authenticate
Wrap the call in a small service so the rest of the app never touches the plugin directly:
\`dart
class BiometricService {
final LocalAuthentication _auth = LocalAuthentication();
Future authenticate() async {
try {
return await _auth.authenticate(
localizedReason: 'Authenticate to unlock your private data',
biometricOnly: false,
persistAcrossBackgrounding: true,
);
} on LocalAuthException {
return false;
}
}
}
`\
Three parameters deserve attention. localizedReason\ is the sentence shown in the system prompt, and on some Android versions the call fails without it. Keep it specific about what is being unlocked.
biometricOnly: false\ is the default behavior and the right choice for an app lock: the system offers biometrics first and falls back to the device PIN, pattern, or passcode when biometrics fail or are unavailable. Set biometricOnly: true\ only when the data genuinely demands a biometric and nothing else, because a user whose fingerprint reader is blocked is then locked out until they re-enter their device passcode. For a health app, locking someone out of their own cycle data is worse than allowing a PIN fallback.
persistAcrossBackgrounding: true\ tells the plugin to wait and retry if the app goes to the background mid-prompt, for example when a phone call interrupts the Face ID sheet. Without it, the call can return false\ for an interruption the user did not cause.
Call this service from your lock screen, not from main()\. The prompt is a system dialog; showing it before the first frame renders is a jarring first impression. Let the user see the lock screen, tap unlock, and then face the system prompt.
Step 5: Handle every failure
authenticate()\ returns false\ when the user cancels, but real failures throw LocalAuthException\. Catch it and map the code to something the user can act on:
| Code | Meaning | What to do |
|---|---|---|
notAvailable\ |
No biometric hardware or no device lock set up | Offer the app PIN fallback |
notEnrolled\ |
Hardware exists but nothing is enrolled | Deep-link the user to system settings |
lockedOut\ |
Too many failed attempts, temporarily blocked | Tell the user to wait and retry |
permanentlyLockedOut\ |
Biometrics disabled until the device passcode is entered | Offer the app PIN fallback |
passcodeNotSet\ |
No device lock screen is configured | Ask the user to set a device passcode first |
\dart
Future<String?> authenticateWithMessage() async {
try {
final bool ok = await _auth.authenticate(
localizedReason: 'Authenticate to unlock your private data',
);
return ok ? null : 'Authentication was cancelled.';
} on LocalAuthException catch (e) {
return switch (e.code) {
'notEnrolled' =>
'No fingerprint or face is enrolled. Add one in Settings to use biometric unlock.',
'lockedOut' =>
'Too many failed attempts. Try again in a few minutes.',
'permanentlyLockedOut' =>
'Biometric unlock is disabled. Enter your device passcode to re-enable it.',
_ => 'Biometric authentication is unavailable on this device.',
};
}
}
\\
Never show the raw exception code to the user, and never retry automatically after a lockout. Each failed attempt extends the lockout, so an automatic retry loop is how a thirty-second block becomes a thirty-minute one.
Step 6: Always ship a fallback
Biometrics are a convenience, not a guarantee. Fingers get wet, faces change with a new pair of glasses, and a phone can be handed to someone whose biometrics were never enrolled. An app lock with no fallback is an app that locks its owner out.
HerCycle ships a PIN alongside the biometric lock for exactly this reason. The pattern is simple: if biometrics are unavailable, not enrolled, or locked out, offer the app PIN. Store the PIN as a hash in secure storage, never as plain text in shared preferences, and keep the PIN screen in your own UI so it works identically on every device. The device PIN fallback that biometricOnly: false\ provides is a good second layer, but your own app PIN is the one you control, and it works even when the user has no device lock configured at all.
Step 7: Privacy rules for biometric apps
A biometric prompt creates an expectation: this app takes my privacy seriously. Break that expectation once and the feature becomes a liability. Four rules keep the promise intact.
First, never collect, store, or transmit biometric data. You cannot do it by accident with local_auth\ because the plugin never gives you the data, but do not work around that with camera or sensor packages of your own. The moment biometric data touches your server, you own a breach you cannot fix.
Second, re-authenticate before sensitive actions, not just at launch. Unlocking the app once and then letting anyone export health reports or view full history for the rest of the session defeats the lock. Prompt again before exports, before showing full records, and before changing the lock settings themselves. The case study for this pattern is HerCycle, where cycle data, symptom logs, and health reports stay on-device behind the app lock.
Third, lock on background. Listen for the app lifecycle and require authentication again when the app returns from the background after a short timeout, five minutes is a reasonable default. A health app left open on a shared table is an unlocked health app.
Fourth, keep secrets behind secure storage. local_auth\ proves identity; it does not protect data at rest. Anything sensitive, such as API tokens or an encrypted backup key, belongs in flutter_secure_storage\, which ties storage to the OS keystore. The two packages solve different halves of the same problem, and the hardening checklist in Production Mobile Security: Hardening Apps Against Attacks covers the rest of the surface, from certificate pinning to rooted-device detection.
Testing checklist
Biometrics cannot be fully tested in an emulator, so test on a real device and work through this list before release: enroll a fingerprint and a face, then authenticate with each; remove all enrolled biometrics and confirm the fallback appears instead of a dead button; fail authentication five times and confirm the lockout message is accurate; background the app mid-prompt with an incoming call and confirm the prompt recovers; and rotate the device and change orientation during the prompt, since some Android skins dismiss the sheet on configuration change. Also test on a device with only a weak face unlock, where Android reports BiometricType.weak\: decide in advance whether weak biometrics may unlock sensitive data, because the OS will happily accept a photo-grade match unless you require strong biometrics.
The pattern in one paragraph
Check the device before you prompt, authenticate through a thin service, let the OS fall back to the device PIN, keep your own app PIN as the last resort, map every failure to a message the user can act on, and never let biometric data leave the secure hardware. That is the whole integration. It is a few dozen lines of Dart and two platform configuration changes, and it is the difference between a privacy promise your users believe and one they test once and delete.
Get in touch and let's talk about your project.
Originally published at uzairxdev.online
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.