Part 5 of the Flutter Security Beyond the Basics series.
The misconception that ships in production
There is a mental model most Flutter developers carry around, often without examining it: "The user scans their fingerprint, so the user is authenticated." It feels right. A fingerprint is unique. It identifies a person. Therefore, biometric authentication is authentication. Ship it.
This model is wrong in a way that matters.
Biometric authentication on a mobile device answers exactly one question: "Is the person holding this phone right now someone whose fingerprint or face is enrolled on this device?" That is it. It is device-local identity verification. It tells your app that the operating system recognises the person physically present. It says nothing about who that person is in the context of your server, your database, or your API.
A fingerprint does not prove identity to your backend. It proves the person holding the phone is one of the people enrolled on that phone. If a user has enrolled three family members' fingerprints on a shared iPad, all three pass the biometric check. Your app cannot tell them apart. Your server never sees a fingerprint. No biometric data crosses the network. The server does not know whether the user tapped "authenticate" with their thumb or their nose — it only receives whatever token your app decides to send after the biometric check succeeds.
This distinction is not academic. If you treat a successful biometric prompt as proof that the user is authenticated with your server, you have a gap. The biometric check might succeed while the user's session has expired, their account has been suspended, or their refresh token has been revoked. The correct pattern — which we will build in this post — uses biometrics as a gate to unlock stored credentials, not as a replacement for them.
What local_auth actually does under the hood
The local_auth package is the standard Flutter package for biometric authentication. It is a thin wrapper around platform-specific biometric APIs, and understanding what those APIs do clarifies what your app is actually asking for.
iOS: the Secure Enclave
On iOS, local_auth calls Apple's LocalAuthentication framework, specifically the LAContext.evaluatePolicy method. This method communicates with the Secure Enclave — a dedicated hardware component on the device's system-on-chip that stores biometric templates (the mathematical representations of the user's face or fingerprints).
The critical design choice Apple made: your app never sees the biometric data. The Secure Enclave performs the comparison entirely within the hardware. Your app sends a request that amounts to "please verify the user" and receives back a boolean: yes or no. The actual fingerprint minutiae or Face ID depth map never leaves the Secure Enclave, never enters your app's memory, and never crosses a process boundary. There is no API — public or private — that lets an app read the raw biometric data.
This is intentional and it is good. It means a compromised app cannot exfiltrate fingerprints. A malicious SDK bundled into your dependency tree cannot harvest biometric data. The hardware enforces the boundary.
Android: BiometricPrompt
On Android, local_auth calls the BiometricPrompt API (for devices running Android 9+, which is virtually all devices in use today). The architecture mirrors iOS in principle: biometric data is stored and matched in a Trusted Execution Environment (TEE) or a dedicated secure element, depending on the device manufacturer.
Android adds another layer of protection: the system UI. When your app requests biometric authentication, Android renders the prompt using a system-level dialog that your app cannot draw over, customise, or intercept. This prevents a class of attacks where a malicious app overlays a fake fingerprint prompt to capture the user's biometric. The system controls the entire interaction — your app just waits for the result.
On both platforms, the answer your app receives is the same: the user passed, the user failed, or the user cancelled. That is the entire vocabulary of the conversation between your app and the biometric system. There is no "which fingerprint matched" or "confidence score" or "biometric template hash." Yes or no.
The correct pattern: biometric gate, then secure storage, then server auth
Knowing that biometrics only answer "is an enrolled person present," the correct architecture chains three steps together:
- User opens the app
- Biometric prompt appears — the user scans fingerprint or face
- On success, the app reads the stored authentication token from
flutter_secure_storage - The app uses that token for API calls to the backend
The biometric check does not replace the token. It gates access to the token. The server never knows or cares that a biometric check happened. It receives a valid JWT (or whatever credential you use) and processes the request normally.
This pattern gives you two layers. The token provides server-side authentication — the server can verify it, check expiry, enforce revocation. The biometric check provides device-side assurance — only an enrolled person can trigger the token to be read and used. If the token has expired or been revoked, the biometric check succeeds but the API call fails, which is the correct behaviour. The user is present, but their session is no longer valid.
Here is the full flow implemented:
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
class AuthGate {
final LocalAuthentication _localAuth = LocalAuthentication();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
/// Attempts biometric verification, then retrieves the stored token.
/// Returns the token if both steps succeed, null otherwise.
Future<String?> unlockSession() async {
final isAvailable = await _localAuth.canCheckBiometrics;
final isDeviceSupported = await _localAuth.isDeviceSupported;
if (!isAvailable || !isDeviceSupported) {
// Device has no biometric capability — fall back to app-level
// PIN or password. Do not silently skip the gate.
return null;
}
final didAuthenticate = await _localAuth.authenticate(
localizedReason: 'Verify your identity to access the app',
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
sensitiveTransaction: true,
),
);
if (!didAuthenticate) {
return null;
}
// Biometric passed — now read the token from secure storage
final token = await _secureStorage.read(key: 'access_token');
return token;
}
}If you have read Post 1 on secure storage and Post 3 on auth tokens, the pieces connect: the token was stored securely using Keychain (iOS) or Android Keystore-backed encryption (Android), and the biometric check is the human-verification step before that token is retrieved and used.
local_auth implementation in detail
The local_auth API is small, but several of its methods and options have subtleties that affect both security and user experience.
Checking biometric availability
There are two separate checks, and they answer different questions:
final localAuth = LocalAuthentication();
// Can the device perform biometric checks?
// Returns true if fingerprint/face hardware exists AND at least one
// biometric is enrolled.
final canCheckBiometrics = await localAuth.canCheckBiometrics;
// Is the device capable of any form of local authentication?
// This includes biometrics AND device-level credentials (PIN, pattern,
// password). Returns true even if no biometrics are enrolled, as long
// as the device has a screen lock set.
final isDeviceSupported = await localAuth.isDeviceSupported;The distinction matters. A device might have a fingerprint sensor but no fingerprints enrolled — canCheckBiometrics returns false, but isDeviceSupported returns true because the user has a PIN. If you only check canCheckBiometrics, you will incorrectly tell users with no enrolled biometrics that their device is unsupported, even though they could authenticate with their device PIN.
Querying available biometric types
final availableBiometrics = await localAuth.getAvailableBiometrics();
// Returns a List<BiometricType> which may contain:
// - BiometricType.fingerprint
// - BiometricType.face
// - BiometricType.iris (rare, some Samsung devices)
// - BiometricType.strong (Android — hardware-backed biometric)
// - BiometricType.weak (Android — software-based biometric)You generally do not need to branch your logic based on biometric type. The authenticate() method handles whichever biometric is available. But this method is useful for UI — you might show "Scan your fingerprint" versus "Use Face ID" based on what the device offers.
The authenticate method and its options
final didAuthenticate = await localAuth.authenticate(
localizedReason: 'Verify your identity to continue',
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
sensitiveTransaction: true,
),
);Each option controls specific behaviour:
`biometricOnly` — When true, only fingerprint, face, or iris is accepted. When false (the default), the system falls back to PIN, pattern, or password if biometric authentication fails or is unavailable. For most apps, false is the correct choice. You want the user to be able to get in even if the fingerprint sensor is wet, dirty, or malfunctioning. Setting this to true is appropriate only when you need to prove physical presence specifically — for instance, authorising a financial transaction where a PIN is considered insufficient.
`stickyAuth` — This one deserves careful attention. When false (the default), if the user backgrounds the app while the biometric prompt is showing, the authentication is silently cancelled and authenticate() returns false. This creates a UX bug that looks like a security gap: the user accidentally taps the home button while the fingerprint dialog is up, comes back, and is told authentication failed. They have no idea why. They try again, it works, and they lose trust in the mechanism.
When true, the biometric prompt survives the app being backgrounded. The user switches away, comes back, and the prompt is still there waiting. This is almost always what you want. The only reason to leave it false is if you want the act of leaving the app to be treated as a cancellation of the sensitive operation — a reasonable stance for a banking app confirming a transfer, but not for an app-unlock gate.
`sensitiveTransaction` — On Android, this sets the setConfirmationRequired(true) flag on the biometric prompt, which requires the user to explicitly confirm after the biometric match. Without it, a successful face scan might immediately proceed without the user realising the action was confirmed. For operations with consequences — approving a payment, deleting data — this extra confirmation step is worth the minor friction.
A complete BiometricGate class
Putting the above together into a reusable class:
import 'package:local_auth/local_auth.dart';
class BiometricGate {
final LocalAuthentication _auth = LocalAuthentication();
/// Returns true if the device has any local authentication capability.
Future<bool> get isSupported async {
return await _auth.isDeviceSupported;
}
/// Returns true if biometric hardware exists and at least one
/// biometric is enrolled.
Future<bool> get hasBiometrics async {
return await _auth.canCheckBiometrics;
}
/// Returns the list of enrolled biometric types for UI purposes.
Future<List<BiometricType>> get availableTypes async {
return await _auth.getAvailableBiometrics();
}
/// Prompts the user for biometric (or device credential) verification.
///
/// [reason] is shown to the user explaining why verification is needed.
/// [biometricOnly] restricts to biometric methods only (no PIN fallback).
/// [sensitive] adds an explicit confirmation step on Android.
Future<bool> verify({
required String reason,
bool biometricOnly = false,
bool sensitive = false,
}) async {
final supported = await isSupported;
if (!supported) return false;
try {
return await _auth.authenticate(
localizedReason: reason,
options: AuthenticationOptions(
biometricOnly: biometricOnly,
stickyAuth: true,
sensitiveTransaction: sensitive,
),
);
} on Exception {
// PlatformException can be thrown if the user has no enrolled
// biometrics and biometricOnly is true, among other cases.
return false;
}
}
}When to gate the whole app versus individual operations
There are two distinct patterns for where you place biometric checks, and which one you choose depends on what your app does and what threat you are defending against.
Whole-app gate
The user opens the app, a biometric prompt appears immediately, and nothing is accessible until it passes. Banking apps do this. Health record apps do this. Any application where merely seeing the data on screen constitutes a breach should do this.
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> with WidgetsBindingObserver {
bool _isLocked = true;
final _gate = BiometricGate();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_unlock();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
// Re-lock when returning from background
setState(() => _isLocked = true);
_unlock();
}
}
Future<void> _unlock() async {
final passed = await _gate.verify(
reason: 'Verify your identity to open the app',
);
if (passed && mounted) {
setState(() => _isLocked = false);
}
}
@override
Widget build(BuildContext context) {
if (_isLocked) {
return const LockScreen();
}
return const MainApp();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}Note the didChangeAppLifecycleState handler: when the user switches back to the app, it re-locks. Without this, an attacker who picks up an already-unlocked phone has full access. Whether you re-lock on every resume or only after a timeout (say, 30 seconds in the background) depends on your security requirements.
Per-operation gate
The app itself is freely accessible, but specific sensitive actions require biometric verification before proceeding. Viewing saved payment methods. Confirming a money transfer. Changing the account password. Exporting personal data.
Future<void> onConfirmTransfer(Transfer transfer) async {
final gate = BiometricGate();
final verified = await gate.verify(
reason: 'Confirm your identity to approve this transfer',
sensitive: true,
);
if (!verified) {
showSnackBar('Verification required to complete this action');
return;
}
await transferService.execute(transfer);
}Per-operation gating is usually the better default. It is proportionate — the friction is applied where the risk is, not everywhere. A user checking their order history does not need to scan their fingerprint. A user changing their delivery address to a new one might. A user viewing their saved credit card number certainly does.
The whole-app gate makes sense when the data itself is sensitive regardless of what action is being taken. If seeing the screen is the threat, gate the screen.
Platform setup requirements
local_auth requires platform-specific configuration that the package cannot set up for you. Missing these steps produces runtime errors or, worse, app store rejections.
iOS: Face ID usage description
Apple requires that any app using Face ID declare why in its Info.plist. Without this entry, the app crashes when attempting Face ID on a physical device, and App Review will reject the submission.
<!-- ios/Runner/Info.plist -->
<dict>
<!-- ... other entries ... -->
<key>NSFaceIDUsageDescription</key>
<string>This app uses Face ID to verify your identity before accessing your account.</string>
</dict>The string should be a clear, honest explanation of why you need Face ID. "We use Face ID for security" is vague. "This app uses Face ID to verify your identity before accessing your account" tells the user exactly what to expect.
Touch ID does not require a separate usage description — it is covered by the LocalAuthentication framework entitlement that local_auth includes automatically.
Android: biometric permission
Add the biometric permission to your Android manifest:
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- ... rest of manifest ... -->
</manifest>On older devices (pre-Android 9), you may also need USE_FINGERPRINT, though this permission is deprecated:
<uses-permission android:name="android.permission.USE_FINGERPRINT" />The local_auth package's Android implementation targets BiometricPrompt, which requires API level 28 (Android 9). If your minSdkVersion is lower than 28, isDeviceSupported will return false on those older devices, and the biometric flow simply will not be offered. This is the correct degradation — do not try to hack around it with deprecated APIs.
Edge cases and honest limitations
Biometric authentication is useful, but it has boundaries that are worth understanding clearly rather than discovering in production.
No biometrics enrolled
A device might have a fingerprint sensor but no fingerprints registered. This is common with new devices, devices that have been factory reset, or users who have deliberately chosen not to enrol biometrics.
canCheckBiometrics returns false in this case. Your app needs a fallback path — typically the device's PIN or password (handled automatically when biometricOnly is false), or your own app-level PIN screen.
The worst response to "no biometrics available" is to skip the gate entirely. If your app contains sensitive data, the absence of biometrics should trigger an alternative verification method, not no verification at all.
Future<bool> verifyWithFallback() async {
final gate = BiometricGate();
final hasBiometrics = await gate.hasBiometrics;
final isSupported = await gate.isSupported;
if (hasBiometrics) {
return gate.verify(reason: 'Verify your identity');
}
if (isSupported) {
// No biometrics, but device has PIN/pattern/password.
// authenticate() with biometricOnly: false will use device
// credentials as fallback.
return gate.verify(reason: 'Enter your device PIN to continue');
}
// Device has no security at all — no biometrics, no screen lock.
// This is a policy decision: do you allow access, or do you require
// the user to set up device security first?
return false;
}Multiple people enrolled on the same device
This is the limitation most developers overlook. If a user has enrolled their own fingerprint and their partner's fingerprint on the same phone — or more commonly, a family iPad where three people have registered Face ID — then any of those enrolled people will pass the biometric check. Your app receives "yes, an enrolled person is present." It does not receive "the account holder is present."
There is no API to distinguish between enrolled biometric profiles. The operating system does not expose which specific fingerprint or face matched. From your app's perspective, all enrolled users are equally valid.
For most apps, this is acceptable — if the device owner chose to enrol other people, they are implicitly granting those people access. But for apps handling financial data or medical records, you should be aware that the biometric gate does not guarantee single-user access. The server-side token is what binds the session to a specific account. The biometric check only gates access to that token.
Rooted and jailbroken devices
On a rooted Android device or a jailbroken iOS device, the biometric subsystem can be compromised. Tools exist that can intercept the biometric API calls and return a spoofed "success" response without any actual biometric verification taking place.
This is not a vulnerability in local_auth or in your app — it is a consequence of the device's security model being broken at the operating system level. If the OS itself has been modified to lie about biometric results, no app-level code can detect this through the biometric API alone.
The mitigation is root and jailbreak detection, which we will cover in Post 8. The short version: you can detect common indicators of a compromised device and refuse to operate, but determined attackers with physical access to a rooted device can bypass most detection mechanisms too. Security on a compromised device is a fundamentally different problem from security on a stock device.
Device already unlocked
Biometric authentication adds no value if the threat model is "someone picks up my already-unlocked phone." If the user has just authenticated with Face ID, set the phone down, and someone else picks it up within the auto-lock window, the phone is fully unlocked and the biometric gate will not trigger unless the app specifically re-locks itself (as shown in the whole-app gate example above, using didChangeAppLifecycleState).
Even with re-locking on resume, there is a window: if the attacker opens the app before the user locks the phone, the app is already unlocked. Per-operation gating helps here — even if the attacker is inside the app, they cannot perform sensitive actions without passing a fresh biometric check.
Putting it together
Biometric authentication in Flutter is straightforward to implement and easy to misunderstand. The implementation is a few dozen lines of code. The misunderstanding is treating it as server authentication when it is device-local identity verification.
The correct architecture:
- Biometric prompt verifies that an enrolled person is physically present
- On success, the app reads the stored authentication token from secure storage
- The token is sent to the server, which validates it on its own terms
- The server neither knows nor cares that a biometric check happened
The biometric check protects against someone else using the device. The token protects the server-side session. Neither replaces the other.
If you are implementing this in a production app, start with the BiometricGate class from this post, wire it into either a whole-app gate or per-operation checks depending on your data sensitivity, and make sure you handle the edge cases: no biometrics enrolled, no device security at all, and the stickyAuth behaviour that silently cancels authentication when your app is backgrounded.
Next in the series, Post 6 covers code obfuscation and reverse engineering — what an attacker sees when they decompile your release APK, and what you can do about it.