Biometric Auth with local_auth
Biometric login feels like magic until TestFlight users report "authentication failed" on every attempt. The local_auth plugin is thin glue over iOS LocalAuthentication and Android BiometricPrompt—two APIs with different edge cases, fallback rules, and permission requirements. I've shipped biometric re-auth in three Flutter apps; the Dart code is twenty lines. The other eighty percent is platform config, secure storage, and handling the user who disabled Face ID last week.
Setup and permissions
Add to pubspec.yaml:
dependencies:
local_auth: ^2.3.0
flutter_secure_storage: ^9.2.2
iOS — Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>Unlock your account quickly with Face ID</string>
Without this key, authenticate() fails silently or throws on device.
Android — AndroidManifest.xml:
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
For Android 9 and below, USE_FINGERPRINT as fallback. local_auth handles BiometricPrompt vs legacy fingerprint manager internally.
Checking availability
Never assume biometrics exist:
final auth = LocalAuthentication();
Future<bool> canUseBiometrics() async {
if (!await auth.isDeviceSupported()) return false;
if (!await auth.canCheckBiometrics()) return false;
final types = await auth.getAvailableBiometrics();
return types.isNotEmpty;
}
getAvailableBiometrics() returns BiometricType.face, .fingerprint, .strong, .weak depending on platform. Use this to customize UI copy ("Use Face ID" vs "Use fingerprint").
The authenticate call
Future<bool> authenticateWithBiometrics() async {
try {
return await auth.authenticate(
localizedReason: 'Verify your identity to access your account',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: false, // allow PIN/pattern fallback on Android
useErrorDialogs: true,
sensitiveTransaction: true,
),
);
} on PlatformException catch (e) {
if (e.code == auth_error.notAvailable ||
e.code == auth_error.notEnrolled) {
return false;
}
if (e.code == auth_error.lockedOut ||
e.code == auth_error.permanentlyLockedOut) {
// Too many failures — force password login
return false;
}
rethrow;
}
}
Key options:
stickyAuth: true— survives app backgrounding mid-prompt (Android).biometricOnly: false— allows device PIN when biometrics unavailable. Settrueonly if policy requires hardware biometrics specifically.sensitiveTransaction: true— iOS treats as payment-grade auth.
Secure storage pattern
Biometric auth should unlock stored credentials, not replace server authentication:
class BiometricSessionStore {
final _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const _refreshTokenKey = 'refresh_token';
Future<void> saveRefreshToken(String token) async {
await _storage.write(key: _refreshTokenKey, value: token);
}
Future<String?> unlockWithBiometrics() async {
final ok = await authenticateWithBiometrics();
if (!ok) return null;
return _storage.read(key: _refreshTokenKey);
}
}
Flow:
- User logs in with password → server returns refresh token → store in secure storage.
- Next launch → prompt biometrics → read token → exchange for session.
- Biometric failure → fall back to password login screen.
On iOS, consider IOSOptions(accessibility: KeychainAccessibility.whenPasscodeSetThisDeviceOnly) for stricter binding to device passcode.
UX patterns that work
Opt-in, not forced. Offer "Enable Face ID login?" after first successful password auth—not on cold install before trust exists.
Visible fallback. Always show "Use password instead" below the biometric prompt trigger.
Re-auth for sensitive actions. Transfer money, change email—biometric again even if session is valid. Use a short in-memory "elevated until" timestamp (5 minutes).
Handle enrollment changes. iOS invalidates Keychain items when biometrics are added/removed. Catch read failures and require password re-login:
try {
return await _storage.read(key: _refreshTokenKey);
} on PlatformException {
await _storage.deleteAll();
return null; // Force full login
}
Platform differences to test
| Scenario | iOS | Android |
|---|---|---|
| No biometrics enrolled | Passcode fallback if allowed | PIN/pattern via BiometricPrompt |
| Too many failures | lockout → passcode | lockedOut error code |
| App in background during prompt | stickyAuth helps |
May cancel prompt |
| Emulator testing | Limited Face ID simulation | Emulator fingerprint via adb |
Test matrix: iPhone with Face ID, Android with fingerprint, device with biometrics disabled, device with no lock screen (should refuse or fallback gracefully).
Common production bugs
- Missing Info.plist key — #1 iOS release failure.
biometricOnly: trueon passcode-only devices — instant failure.- Storing passwords in SharedPreferences — trivially extracted; use secure storage.
- Not handling
permanentlyLockedOut— user stuck in retry loop. - Calling authenticate on web/desktop — returns false; guard with platform checks.
Wrap biometric features in if (Platform.isIOS || Platform.isAndroid) and provide password-only path elsewhere.
Compliance and threat modeling
Biometric auth satisfies convenience, not high-assurance MFA alone. For PCI or HIPAA flows, pair biometrics with server-side step-up verification for sensitive mutations. Log biometric unlock events locally for fraud investigation—timestamp and success/failure without storing biometric data (you can't and shouldn't).
On Android, confirm BIOMETRIC_STRONG vs BIOMETRIC_WEAK matches your policy; face unlock on some devices registers as weak. Use local_auth Android options or platform checks when regulations require Class 3 biometrics only.
Test biometric flow after device migration restore—Keychain items may not transfer as expected. Enterprise MDM policies can disable biometrics org-wide; detect early and hide biometric login UI rather than showing broken prompts that confuse users.
Production teams should treat this guidance as living documentation: revisit assumptions after major platform upgrades, measure outcomes with real metrics rather than checklist compliance, and pair written standards with automated checks in CI. The patterns here reflect what held up in shipped apps—not theoretical perfection. Adapt thresholds, timeouts, and tooling to your stack, but keep the underlying principles: explicit configuration, testable behavior, and failure modes users can understand.
When onboarding new engineers, walk through one end-to-end example in a debug build before asking them to extend the pattern. Most failures I have seen came from skipped platform setup steps—manifest entries, API keys, code generation, or permission prompts—not from misunderstanding the Dart layer. Keep a troubleshooting section in your team wiki linking official docs and the exact commands that worked for your last upgrade.
Resources
- local_auth package
- flutter_secure_storage package
- Apple LocalAuthentication framework
- Android BiometricPrompt guide
- local_auth API reference
Frequently asked questions
How does local_auth work in Flutter?
The local_auth plugin wraps iOS LocalAuthentication and Android BiometricPrompt APIs. You check device capability with canCheckBiometrics and isDeviceSupported, then call authenticate with localized reason strings. Successful auth returns true; failures and cancellations throw PlatformException with specific error codes you must handle.
Should I store passwords with biometric auth in Flutter?
Never store raw passwords behind biometrics. Store a short-lived refresh token or session key in flutter_secure_storage, gated by biometric re-authentication. Biometrics unlock access to the secure enclave item—they are not encryption themselves. Treat biometric login as convenience re-entry, not primary authentication.
Why does local_auth fail on iOS release builds?
Missing NSFaceIDUsageDescription in Info.plist causes immediate failure on physical devices. Test on real hardware—simulators don't exercise the full LocalAuthentication stack. Also verify you haven't disabled biometrics by calling authenticate with biometricOnly: true on devices with only passcode enrolled.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →