biometric_guard 1.0.4
biometric_guard: ^1.0.4 copied to clipboard
Flutter plugin for biometric and device authentication on Android & iOS, featuring session management, widget/navigation-level guards, and reactive UI state.
Biometric Guard ππ±
A Flutter plugin for biometric and device-credential authentication on Android and iOS.
Session management Β· navigation-level guards Β· reactive state Β· resume-on-foreground β all through one clean API.
Why Biometric Guard ? #
Most biometric plugins give you a raw authenticate() call and leave the rest to you. biometric_guard goes further:
- Session-aware β re-prompts only when the session has actually expired, not on every navigation
- Lifecycle-safe β handles background/foreground transitions on both platforms without crashing or ghost-prompts
- Typed results β every outcome is a strongly-typed
AuthResult; no magic strings to switch on - Two guard styles β wrap a widget (
BiometricGuard) or a route (BiometricRoute) depending on your architecture - Reactive UI β
SessionStateBuilderrebuilds your AppBar lock icon, session banners, etc. automatically - Testable β inject a mock channel via
BiometricSessionManager.forTesting()
Platform support #
| Platform | Min version | Touch ID / Fingerprint | Face ID / Face Auth | Passcode / PIN | Resume on foreground |
|---|---|---|---|---|---|
| Android | API 23 (6.0) | β | β | β | β |
| iOS | iOS 13.0 | β (Touch ID) | β (Face ID) | β (passcode) | β |
Table of contents #
- Installation
- Android setup
- iOS setup
- Quick start
- BiometricGuard
- BiometricRoute
- BiometricSessionManager
- SessionStateBuilder
- AuthOptions
- AuthIntent & AuthContents
- AuthResult
- Platform behaviour differences
- Error reference
- Testing
- FAQ
Installation #
dependencies:
biometric_guard: ^1.0.4
flutter pub get
Android setup #
1 Β· Change MainActivity base class #
Open android/app/src/main/kotlin/.../MainActivity.kt:
// β Before
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
// β
After
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
BiometricPromptrequires aFragmentActivity. Without this change the plugin logs a warning and returnsAuthResultCode.notFragmentActivityon every call.
2 Β· Add permissions #
android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
3 Β· Minimum SDK #
android/app/build.gradle:
android {
defaultConfig {
minSdkVersion 23
}
}
iOS setup #
1 Β· Add usage description #
ios/Runner/Info.plist:
<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to verify your identity.</string>
This key is required for App Store submission whenever Face ID is possible on the device, even if you also support Touch ID or passcode. The app will crash at runtime on Face ID devices without it.
2 Β· Minimum deployment target #
In Xcode β Runner target β General β Minimum Deployments, set iOS 13.0 or higher.
Or in ios/Podfile:
platform :ios, '13.0'
3 Β· No extra permissions needed #
Touch ID, passcode, and Face ID are handled automatically by LocalAuthentication. No additional Info.plist keys are required beyond NSFaceIDUsageDescription.
Quick start #
import 'package:biometric_guard/biometric_guard.dart';
final result = await BiometricSessionManager.instance.authenticate(
intent: AuthIntent.secure,
);
if (result.isSuccess) {
// result.type β AuthType.biometric or AuthType.deviceCredential
print('Authenticated via ${result.type}');
} else if (result.isCanceled) {
print('User cancelled');
} else if (result.isLockedOut) {
print('Too many attempts β sensor locked');
} else {
print('Failed: ${result.message}');
}
BiometricGuard #
A widget that wraps your screen and authenticates only when the global session has expired. If the session is still valid, child is shown immediately β no prompt, no flicker.
BiometricGuard(
intent: AuthIntent.secure,
sessionTimeout: const Duration(minutes: 5),
onSuccess: () => Navigator.pushNamed(context, '/home'),
onFailure: () => _showAuthErrorDialog(context),
child: const HomeScreen(),
)
Custom prompt strings #
BiometricGuard(
intent: AuthIntent.custom,
sessionTimeout: const Duration(minutes: 2),
contents: const AuthContents(
title: 'Unlock Vault',
subtitle: 'Use your fingerprint or face',
description: 'Your data is protected by biometric lock',
),
onSuccess: () { /* ... */ },
onFailure: () { /* ... */ },
child: const VaultScreen(),
)
Custom loading and lockout UI #
BiometricGuard(
intent: AuthIntent.payment,
sessionTimeout: const Duration(minutes: 1),
onSuccess: () { /* ... */ },
onFailure: () { /* ... */ },
loadingBuilder: (context) => const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator.adaptive(),
SizedBox(height: 16),
Text('Verifying identityβ¦'),
],
),
),
lockedBuilder: (context, result) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(result.message, textAlign: TextAlign.center),
],
),
),
child: const PaymentScreen(),
)
BiometricGuard parameter reference #
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
intent |
AuthIntent |
β | β | Drives default prompt strings |
child |
Widget |
β | β | Screen shown when session is valid |
onSuccess |
VoidCallback |
β | β | Called when session is valid or auth succeeds |
onFailure |
VoidCallback |
β | β | Called on failure, cancel, or lockout |
sessionTimeout |
Duration |
5 minutes |
How long a successful auth stays valid | |
options |
AuthOptions |
AuthOptions() |
Biometric-only mode, resume-on-foreground | |
contents |
AuthContents? |
derived from intent |
Override prompt title / subtitle / description | |
loadingBuilder |
WidgetBuilder? |
adaptive spinner | Widget shown while prompt is active | |
lockedBuilder |
Function(BuildContext, AuthResult)? |
lock message | Widget shown on sensor lockout | |
manager |
BiometricSessionManager? |
singleton | Override for testing |
BiometricRoute #
A PageRoute that authenticates before showing the destination screen. Unlike BiometricGuard it has no session logic β it always prompts. On failure it pops itself automatically and returns the AuthResult to the caller.
await Navigator.push(
context,
BiometricRoute(
intent: AuthIntent.payment,
builder: (_) => const PaymentScreen(),
),
);
Inspect the failure reason after pop #
final result = await Navigator.push<AuthResult>(
context,
BiometricRoute(
intent: AuthIntent.credentials,
builder: (_) => const SecretsScreen(),
onAuthFailure: (result) {
// Called before the route pops β good for snackbars on the source screen
if (result.isLockedOut) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Too many attempts. Try again later.')),
);
}
},
),
);
// null β user navigated back normally after successful auth
// non-null β auth failed; inspect result.code
if (result != null && !result.isSuccess) {
print('Auth failed with: ${result.code}');
}
Biometric-only (no PIN/passcode fallback) #
Navigator.push(
context,
BiometricRoute(
intent: AuthIntent.payment,
options: const AuthOptions(biometricOnly: true),
builder: (_) => const PaymentScreen(),
),
);
BiometricRoute parameter reference #
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
builder |
WidgetBuilder |
β | β | Screen shown after successful auth |
intent |
AuthIntent |
β | β | Auth intent |
options |
AuthOptions |
AuthOptions() |
Auth options | |
contents |
AuthContents? |
derived from intent |
Override prompt strings | |
onAuthFailure |
Function(AuthResult)? |
null |
Called before auto-pop on failure | |
manager |
BiometricSessionManager? |
singleton | Override for testing |
BiometricSessionManager #
The singleton that owns all authentication state. Every BiometricGuard and BiometricRoute uses it internally. Call it directly when you need raw API access.
final manager = BiometricSessionManager.instance;
authenticate() #
final result = await BiometricSessionManager.instance.authenticate(
intent: AuthIntent.secure,
options: const AuthOptions(resumeOnForeground: true),
// contents: const AuthContents(title: '...'), // optional override
);
cancelAuthentication() #
Cancels the active native prompt. The suspended authenticate() future resolves with AuthResultCode.userCanceled.
await BiometricSessionManager.instance.cancelAuthentication();
isSessionValid() #
Synchronous. Returns true if the last successful auth happened within timeout.
final valid = BiometricSessionManager.instance
.isSessionValid(const Duration(minutes: 5));
invalidateSession() #
Forces every guard to prompt on its next check. Call this on logout or account switch.
BiometricSessionManager.instance.invalidateSession();
isDeviceSupported() #
Returns true if the device has any authentication method available (biometric or screen lock / passcode).
final supported = await BiometricSessionManager.instance.isDeviceSupported();
if (!supported) {
// Guide the user to Settings to set up a screen lock or enrol biometrics
}
getAvailableTypes() #
Returns the hardware authentication types present on the device.
final types = await BiometricSessionManager.instance.getAvailableTypes();
// Android β ['FINGERPRINT', 'FACE', 'IRIS'] (whatever is present)
// iOS β ['FINGERPRINT'] for Touch ID devices
// ['FACE'] for Face ID devices
// ['IRIS'] for Apple Vision Pro (opticID)
// [] if no biometrics available
sessionStream #
Optional reactive stream of SessionState. Subscribe only when UI outside a guard needs to reflect auth changes (e.g. a persistent AppBar lock icon).
BiometricSessionManager.instance.sessionStream.listen((state) {
switch (state) {
case SessionActive(:final authenticatedAt, :final type):
print('Active since $authenticatedAt via $type');
case SessionExpired():
print('Session expired');
case SessionAuthenticating(:final intent):
print('Prompting for $intent');
case SessionFailed(:final result):
print('Failed: ${result.code} β ${result.message}');
}
});
The stream is a broadcast stream β multiple listeners are supported. It does not replay the last event; use
currentState()for the current snapshot.
currentState() #
Synchronous snapshot β no await needed.
final state = BiometricSessionManager.instance
.currentState(timeout: const Duration(minutes: 5));
SessionStateBuilder #
Reactive widget that rebuilds whenever the session stream emits. Use it for UI outside a BiometricGuard β e.g. a lock icon in an AppBar, a session countdown banner, or a global overlay.
AppBar(
title: const Text('My App'),
actions: [
SessionStateBuilder(
sessionTimeout: const Duration(minutes: 5),
builder: (context, state) {
return switch (state) {
SessionActive() => const Icon(Icons.lock_open, color: Colors.green),
SessionExpired() => const Icon(Icons.lock, color: Colors.red),
SessionAuthenticating() => const SizedBox(
width: 20, height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
SessionFailed() => const Icon(Icons.error_outline, color: Colors.orange),
};
},
),
],
)
SessionLockIndicator #
Convenience widget for a simple active/locked toggle β no switch statement needed:
SessionLockIndicator(
sessionTimeout: const Duration(minutes: 5),
activeChild: const Icon(Icons.lock_open, color: Colors.green),
lockedChild: const Icon(Icons.lock, color: Colors.red),
)
AuthOptions #
const AuthOptions({
bool biometricOnly = false,
bool resumeOnForeground = false,
})
| Option | Default | Description |
|---|---|---|
biometricOnly |
false |
When true, disables PIN / pattern / password / passcode fallback. Only fingerprint, face, or iris accepted. |
resumeOnForeground |
false |
When true, the prompt re-appears automatically when the user returns from background. Repeats until success or explicit cancel. |
resumeOnForeground in detail #
await BiometricSessionManager.instance.authenticate(
intent: AuthIntent.secure,
options: const AuthOptions(resumeOnForeground: true),
);
// This Future does not complete until the user either:
// - Authenticates successfully β AuthResultCode.success
// - Taps Cancel explicitly β AuthResultCode.userCanceled / negativeButton
// - Gets locked out β AuthResultCode.lockedOut
//
// Backgrounding and re-foregrounding the app does NOT complete the Future β
// it simply re-shows the prompt 500 ms after the app returns.
Platform handling:
| Event | Android | iOS |
|---|---|---|
| App goes to background | ERROR_CANCELED (code 5) fires from BiometricPrompt |
LAError.systemCancel fires from LAContext |
| Detection method | Error code checked directly (not lifecycle flag) | isPaused flag set by didEnterBackgroundNotification |
| Re-show trigger | onActivityResumed callback |
willEnterForegroundNotification |
| Re-show delay | 500 ms | 500 ms |
AuthIntent & AuthContents #
AuthIntent tells the guard why authentication is happening. It drives the default prompt strings and can be inspected by your own UI.
| Intent | Default title | Default subtitle | Use case |
|---|---|---|---|
AuthIntent.secure |
Authentication Required | Verify your identity to continue | Login, sensitive settings |
AuthIntent.payment |
Confirm Payment | Authenticate to authorise this transaction | Payments, transfers |
AuthIntent.credentials |
Access Credentials | Verify your identity to reveal saved credentials | Password managers, secrets |
AuthIntent.custom |
Authenticate | (empty) | Supply your own AuthContents |
Override any intent's strings with AuthContents:
const AuthContents(
title: 'Unlock Vault', // required
subtitle: 'Fingerprint or face', // optional
description: 'Your data is protected', // optional β shown below subtitle on Android
)
On iOS,
titleandsubtitleare combined intolocalizedReason(the single string shown byLAContext). Thedescriptionfield is not displayed on iOS.
AuthResult #
class AuthResult {
final AuthResultCode code; // the specific outcome β see table below
final String message; // human-readable detail from the platform
final AuthType type; // how the user authenticated
bool get isSuccess => code == AuthResultCode.success;
bool get isCanceled => code == AuthResultCode.userCanceled
|| code == AuthResultCode.negativeButton;
bool get isLockedOut => code == AuthResultCode.lockedOut;
}
AuthResultCode #
| Code | Android trigger | iOS trigger |
|---|---|---|
success |
onAuthenticationSucceeded |
evaluatePolicy success |
userCanceled |
ERROR_USER_CANCELED (10) |
LAError.userCancel / systemCancel / appCancel |
negativeButton |
ERROR_NEGATIVE_BUTTON (13) |
LAError.userFallback (fallback button tapped) |
lockedOut |
ERROR_LOCKOUT (7) / ERROR_LOCKOUT_PERMANENT (9) |
LAError.biometryLockout |
alreadyInProgress |
Native guard in plugin | Native guard in plugin |
noActivity |
No active FragmentActivity |
No root UIViewController |
notFragmentActivity |
MainActivity not FlutterFragmentActivity |
(not applicable on iOS) |
noCredentials |
No biometrics or screen lock set up | LAError.biometryNotAvailable / biometryNotEnrolled / passcodeNotSet |
error |
Any other BiometricPrompt error |
Any other LAError |
AuthType #
| Value | Android | iOS |
|---|---|---|
biometric |
Fingerprint, face, or iris via BiometricPrompt |
Touch ID or Face ID via .deviceOwnerAuthenticationWithBiometrics |
deviceCredential |
PIN, pattern, or password | Passcode via .deviceOwnerAuthentication |
unknown |
Type could not be determined | Type could not be determined |
iOS note: iOS does not expose which specific biometric method was used within a successful
.deviceOwnerAuthenticationevaluation. If the user has both a passcode and Face ID,AuthTypewill bedeviceCredentialeven if they used Face ID β because the policy allows both. UsebiometricOnly: trueinAuthOptionsif you need to guaranteeAuthType.biometric.
Platform behaviour differences #
| Behaviour | Android | iOS |
|---|---|---|
| Prompt UI | System BiometricPrompt bottom sheet |
System LAContext alert |
| Cancel button label | Configurable via AuthContents |
Not configurable β always system default |
| Fallback button | Hidden when biometricOnly: true |
Hidden (localizedFallbackTitle = "") when biometricOnly: true |
description field |
Shown below subtitle in prompt | Not displayed |
| Lockout reset | Automatic after ~30 s; permanent lockout requires device PIN | Same β LAError.biometryLockout covers both |
| Background cancel signal | ERROR_CANCELED (code 5) fires before onPause |
LAError.systemCancel fires; isPaused flag already set by didEnterBackground |
| Lifecycle observer | Application.ActivityLifecycleCallbacks + Jetpack Lifecycle |
NotificationCenter (didEnterBackground + willEnterForeground) |
notFragmentActivity |
Possible if MainActivity is wrong |
Never returned β no equivalent concept |
Error reference #
| Symptom | Cause | Fix |
|---|---|---|
notFragmentActivity result on Android |
MainActivity extends FlutterActivity |
Change to FlutterFragmentActivity β see Android setup |
| App crashes on iOS with Face ID device | Missing NSFaceIDUsageDescription in Info.plist |
Add the key β see iOS setup |
noCredentials result |
No biometrics enrolled or no screen lock / passcode set up | Check isDeviceSupported() at startup; guide user to device Settings |
lockedOut result |
Too many failed attempts | Wait ~30 s for temporary lockout to clear; permanent lockout requires device PIN entry |
| Prompt never appears on Android | Missing USE_BIOMETRIC permission |
Add to AndroidManifest.xml |
alreadyInProgress result |
Two auth calls triggered simultaneously | Expected β only one prompt is shown at a time; the second returns immediately |
resumeOnForeground not working on Android |
MainActivity is not FlutterFragmentActivity |
Lifecycle observer requires a real FragmentActivity |
| Prompt keeps re-appearing | resumeOnForeground: true + user is backgrounding repeatedly |
Intended β tap Cancel on the prompt to end the flow |
| Build error: minSdkVersion | minSdkVersion below 23 |
Set minSdkVersion 23 in android/app/build.gradle |
| Pod install fails | Deployment target below iOS 12 | Set platform :ios, '12.0' in Podfile |
AuthType.deviceCredential returned even when user used Face ID |
iOS cannot distinguish passcode vs biometric on .deviceOwnerAuthentication policy |
Use biometricOnly: true to guarantee biometric-only auth |
Testing #
Inject a fake channel via BiometricSessionManager.forTesting():
import 'package:flutter_test/flutter_test.dart';
import 'package:biometric_guard/biometric_guard.dart';
void main() {
group('BiometricSessionManager', () {
test('returns success and marks session valid', () async {
final manager = BiometricSessionManager.forTesting(
_FakeChannel(AuthResult(
code: AuthResultCode.success,
message: 'ok',
type: AuthType.biometric,
)),
);
final result = await manager.authenticate(intent: AuthIntent.secure);
expect(result.isSuccess, true);
expect(result.type, AuthType.biometric);
expect(manager.isSessionValid(const Duration(minutes: 5)), true);
});
test('session invalid after invalidate', () async {
final manager = BiometricSessionManager.forTesting(
_FakeChannel(const AuthResult(
code: AuthResultCode.success,
message: 'ok',
type: AuthType.biometric,
)),
);
await manager.authenticate(intent: AuthIntent.secure);
manager.invalidateSession();
expect(manager.isSessionValid(const Duration(minutes: 5)), false);
});
test('returns alreadyInProgress on overlapping calls', () async {
final manager = BiometricSessionManager.forTesting(
_SlowFakeChannel(const AuthResult(
code: AuthResultCode.success,
message: 'ok',
type: AuthType.biometric,
)),
);
// Fire two calls simultaneously
final first = manager.authenticate(intent: AuthIntent.secure);
final second = manager.authenticate(intent: AuthIntent.secure);
final secondResult = await second;
expect(secondResult.code, AuthResultCode.alreadyInProgress);
await first; // let it complete cleanly
});
});
}
// βββ Fakes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class _FakeChannel implements BiometricChannel {
_FakeChannel(this._result);
final AuthResult _result;
@override
Future<AuthResult> authenticate({
required AuthOptions options,
required AuthContents contents,
}) async => _result;
@override Future<bool> isDeviceSupported() async => true;
@override Future<List<String>> getAvailableTypes() async => ['FINGERPRINT'];
@override Future<bool> stopAuthentication() async => true;
}
class _SlowFakeChannel implements BiometricChannel {
_SlowFakeChannel(this._result);
final AuthResult _result;
@override
Future<AuthResult> authenticate({
required AuthOptions options,
required AuthContents contents,
}) async {
await Future.delayed(const Duration(milliseconds: 100));
return _result;
}
@override Future<bool> isDeviceSupported() async => true;
@override Future<List<String>> getAvailableTypes() async => ['FINGERPRINT'];
@override Future<bool> stopAuthentication() async => true;
}
FAQ #
Q: Does this work on Android without biometrics enrolled β just PIN/pattern/password?
Yes. isDeviceSupported() returns true if any auth method is set up, including screen lock only. authenticate() will show the device credential prompt if biometricOnly is false.
Q: Can I use multiple BiometricGuard widgets on the same screen?
Yes. All guards share one global session. If the session is valid all pass through immediately. If expired, the first guard to run triggers the prompt β the rest receive alreadyInProgress and the session manager serialises them. This is intentional.
Q: What is the difference between userCanceled and negativeButton?
negativeButton means the user tapped the labelled Cancel button on the prompt (Android) or the fallback Enter Password button (iOS). userCanceled means they dismissed by tapping outside or pressing Back / swipe-down. In most cases you can treat both the same β both are user-initiated cancellations. Use isCanceled getter to handle both with one check.
Q: How do I force re-authentication even when the session is still valid?
Call BiometricSessionManager.instance.invalidateSession() before the guard runs. This clears the timestamp so every guard treats the session as expired on its next check.
Q: Is the session persisted to disk across app restarts? No. The session is in-memory only. Every cold start of the app requires fresh authentication.
Q: Can I use BiometricRoute with named routes (Navigator.pushNamed)?
Not directly β BiometricRoute must be pushed imperatively. Wrap the destination widget in BiometricRoute at the call site inside a Navigator.push().
Q: On iOS, why does AuthType say deviceCredential when the user used Face ID?
When biometricOnly is false, the plugin uses the .deviceOwnerAuthentication policy which allows both biometric and passcode. iOS does not report which method was actually used within that policy. Set biometricOnly: true if you need to guarantee that only biometric was used and get AuthType.biometric back.
Q: Does resumeOnForeground keep the Flutter Future alive across multiple background trips?
Yes. The authenticate() Future stays suspended until the user explicitly cancels or authenticates. Every background/foreground cycle re-shows the prompt. The Future does not time out on its own.
Author #
Built with β€οΈ by Shahul Hameed
If you found this package helpful, consider giving it a β on GitHub and a π on pub.dev!
π License #
This project is licensed under the MIT License β see the LICENSE file for details.