local_auth is the standard Flutter package for Face ID, Touch ID, and Android fingerprint. The appmint_mobile admin app uses it as a second factor on cold start: the JWT is in secure storage, but reading it requires the device biometric. If the user can't authenticate biometrically, the app falls back to the password sign-in flow.
This is in addition to the JWT, not a replacement for it.
When to use it
Biometrics gate user actions, not API calls. AppEngine never sees the biometric — it only sees the JWT. The pattern is:
- App opens → check if biometrics are enabled by the user.
- If yes, prompt biometric → on success, restore session from secure storage.
- If no, show the email/password screen.
- On any sensitive operation (delete account, change password, view a payout), re-prompt biometrics before the API call.
If the user disables biometrics mid-session, you should still let them sign out, but require password for re-entry.
Setup
Add the package:
dependencies:
local_auth: ^2.2.0
iOS
In ios/Runner/Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>Use Face ID to sign in to AppMint quickly and securely</string>
Without this key the prompt will throw on iOS 11+.
Android
In android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
Make MainActivity extend FlutterFragmentActivity instead of FlutterActivity — local_auth requires the AndroidX biometric prompt:
// android/app/src/main/kotlin/.../MainActivity.kt
package com.example.app
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
BiometricService
// lib/services/biometric_service.dart
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:local_auth/error_codes.dart' as auth_errors;
import 'package:shared_preferences/shared_preferences.dart';
class BiometricService {
static final BiometricService _instance = BiometricService._internal();
factory BiometricService() => _instance;
BiometricService._internal();
final _auth = LocalAuthentication();
static const _kEnabled = 'biometric_enabled';
/// Does the device support biometrics at all?
Future<bool> isAvailable() async {
try {
final supported = await _auth.isDeviceSupported();
final canCheck = await _auth.canCheckBiometrics;
return supported && canCheck;
} on PlatformException {
return false;
}
}
/// Has the user opted in?
Future<bool> isEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_kEnabled) ?? false;
}
Future<void> setEnabled(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kEnabled, value);
}
/// Prompt the user. Returns true on success, false if the user cancels
/// or fails. Throws PlatformException on misconfiguration.
Future<bool> authenticate({String reason = 'Sign in to AppMint'}) async {
try {
return await _auth.authenticate(
localizedReason: reason,
options: const AuthenticationOptions(
biometricOnly: false,
stickyAuth: true,
useErrorDialogs: true,
),
);
} on PlatformException catch (e) {
if (e.code == auth_errors.notAvailable ||
e.code == auth_errors.notEnrolled ||
e.code == auth_errors.passcodeNotSet) {
return false;
}
rethrow;
}
}
}
stickyAuth: true keeps the prompt alive when the app is backgrounded mid-prompt (iOS lock screen, incoming call) — without it the prompt cancels and the user starts over. biometricOnly: false falls back to device passcode if biometrics are unavailable, which is what most users expect.
Cold-start gate
In your app bootstrap, prompt biometrics before restoring the session.
// lib/main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final biometric = BiometricService();
final auth = AuthProvider();
// Always wire callbacks first.
await auth.bootstrap();
if (auth.isAuthenticated && await biometric.isEnabled()) {
final ok = await biometric.authenticate(
reason: 'Sign in to AppMint',
);
if (!ok) {
// User cancelled or biometric failed — sign out and show login.
await auth.signOut();
}
}
runApp(
ChangeNotifierProvider.value(value: auth, child: const MyApp()),
);
}
Opt-in flow
The user must explicitly enable biometrics — this is a security boundary, not a default. Show a settings toggle that calls into the service.
// lib/screens/settings/security_screen.dart
SwitchListTile(
title: const Text('Sign in with Face ID'),
subtitle: Text(_available
? 'Use biometrics to unlock the app'
: 'Not available on this device'),
value: _enabled,
onChanged: !_available ? null : (value) async {
if (value) {
// Prompt once to confirm before turning on.
final ok = await BiometricService().authenticate(
reason: 'Confirm to enable biometrics',
);
if (!ok) return;
}
await BiometricService().setEnabled(value);
setState(() => _enabled = value);
},
),
Re-prompting before sensitive actions
Use the same service for ad-hoc gates — viewing a payout, deleting an account, changing a password, exporting data.
Future<void> _confirmAndDelete(String leadId) async {
if (await BiometricService().isEnabled()) {
final ok = await BiometricService().authenticate(
reason: 'Confirm to delete this lead',
);
if (!ok) return;
}
await LeadsService().delete(leadId);
}
Don't prompt for trivial reads — only for actions that materially change data or expose financial information.
Edge cases
- No enrolled biometric.
authenticatereturns false the first time and the underlying error isnotEnrolled. Treat this as "biometrics unavailable" and fall back to password. - Lockout after too many failures. iOS and Android both lock biometrics after 5 failed attempts; the user must enter the device passcode to unlock.
local_authsurfaces this as a generic failure — show "Use password instead" rather than retrying. - User changes their face/fingerprint enrollment. On Android,
LocalAuthentication.deviceSupportsBiometricsreturns true but the prompt may not match prior enrollments. Don't tie the JWT to the biometric — keep them independent so re-enrollment doesn't lock out users. - Background/lock screen. With
stickyAuth: true, a backgrounded app resumes the prompt. Without it, the prompt cancels — handle the cancel by showing the password screen.
What biometrics don't do
Biometrics never travel to AppEngine. There is no "biometric token" exchanged with the server — the JWT in flutter_secure_storage is the only credential the server sees. Biometrics only gate local access to that JWT.
That means:
- A user signed in on a stolen unlocked device can still hit your API. Biometrics protect against the lost-device scenario, not against an attacker who already has the device unlocked.
- A device passcode reset does not invalidate the AppEngine session. If you want server-side revocation, call
POST /profile/signoutfrom settings.
If you need stronger device attestation, look into Play Integrity (Android) and DeviceCheck (iOS) — they're a separate concern from biometrics.