biometric_guard 1.0.4 copy "biometric_guard: ^1.0.4" to clipboard
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 πŸ”πŸ“±

pub.dev pub points platforms license

Biometric Guard Android and iOS Demo

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 β€” SessionStateBuilder rebuilds 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 #

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()

BiometricPrompt requires a FragmentActivity. Without this change the plugin logs a warning and returns AuthResultCode.notFragmentActivity on 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, title and subtitle are combined into localizedReason (the single string shown by LAContext). The description field 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 .deviceOwnerAuthentication evaluation. If the user has both a passcode and Face ID, AuthType will be deviceCredential even if they used Face ID β€” because the policy allows both. Use biometricOnly: true in AuthOptions if you need to guarantee AuthType.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.

2
likes
160
points
158
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter plugin for biometric and device authentication on Android & iOS, featuring session management, widget/navigation-level guards, and reactive UI state.

Repository (GitHub)
View/report issues

Topics

#biometrics #security #authentication #biometric-guard #device-auth

License

MIT (license)

Dependencies

flutter

More

Packages that depend on biometric_guard

Packages that implement biometric_guard