Documentation

Biometrics

Gate sensitive screens behind Face ID, Touch ID, or fingerprint with local_auth.

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 FlutterActivitylocal_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. authenticate returns false the first time and the underlying error is notEnrolled. 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_auth surfaces this as a generic failure — show "Use password instead" rather than retrying.
  • User changes their face/fingerprint enrollment. On Android, LocalAuthentication.deviceSupportsBiometrics returns 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/signout from settings.

If you need stronger device attestation, look into Play Integrity (Android) and DeviceCheck (iOS) — they're a separate concern from biometrics.