Skip to content
ExponaBLOG
FLUTTER · DAY 063 / 100

Biometric authentication: a Flutter guide for developers

Face ID and fingerprint unlock.

Expona Admin9 min read

Biometric authentication: When your users' faces and fingerprints become the key

At some point in your career, you'll need to move beyond passwords. Biometric authentication—unlocking apps with Face ID or fingerprint—is now expected on modern mobile platforms, and Flutter's local_auth plugin makes it genuinely straightforward to implement. I've added biometric flows to trading apps and ride-hailing services where users don't want to re-enter credentials every session, and the difference in friction is real. Today, we're building a production-ready approach: how to prompt for biometrics, safely store credentials behind that biometric gate, and always keep a sensible fallback.

The local_auth plugin: Your entry point

The local_auth package is Flutter's official bridge to iOS's LocalAuthentication framework and Android's BiometricPrompt API. It abstracts away platform differences so you can call one method and get biometric results on both sides.

Start by adding it to your pubspec.yaml:

dart
dependencies:
  flutter:
    sdk: flutter
  local_auth: ^2.2.0

Then update your platform-specific permissions and native configurations. On iOS, add this to Info.plist:

dart
<key>NSFaceIDUsageDescription</key>
<string>We use Face ID to securely authenticate you without re-entering your password.</string>
<key>NSBiometryUsageDescription</key>
<string>We use biometric authentication for fast, secure access to your account.</string>

On Android, update AndroidManifest.xml:

dart
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />

The key thing: those NSFaceIDUsageDescription and NSBiometryUsageDescription strings show up in the system permission prompt. Make them honest and specific about why you need biometrics. "Fast login" is fine. "To track your movements" is not.

Checking biometric availability and types

Before you ask the user to authenticate, check what's actually available on their device. A user on an older Android phone may only support fingerprint, while another has both face and iris. A user on an iPhone without Face ID is wasting everyone's time if you only offer facial recognition.

dart
import 'package:local_auth/local_auth.dart';

class BiometricService {
  final LocalAuthentication _localAuth = LocalAuthentication();

  Future<List<BiometricType>> getAvailableBiometrics() async {
    try {
      return await _localAuth.getAvailableBiometrics();
    } catch (e) {
      print('Error checking biometrics: $e');
      return [];
    }
  }

  Future<bool> canAuthenticateWithBiometrics() async {
    try {
      return await _localAuth.canCheckBiometrics;
    } catch (e) {
      return false;
    }
  }
}

Call getAvailableBiometrics() early in your app—when the user first logs in or during a settings screen. The method returns a list of available types: BiometricType.face, BiometricType.fingerprint, and others. If the list is empty, your biometric button shouldn't even exist on that device.

Requesting biometric authentication with a fallback

The core flow is: ask for biometric authentication, handle success, and provide a fallback path when the user either can't or won't use biometrics. I always build this with a clear escape hatch to password entry.

dart
class BiometricService {
  Future<bool> authenticate() async {
    try {
      final isAuthenticated = await _localAuth.authenticate(
        localizedReason: 'Verify your identity to access your account',
        options: const AuthenticationOptions(
          stickyAuth: true,
          biometricOnly: false,
        ),
      );
      return isAuthenticated;
    } on PlatformException catch (e) {
      print('Biometric auth error: ${e.code}');
      return false;
    }
  }
}

Key parameters here:

  • localizedReason: This string appears in the biometric prompt. Keep it conversational and brief.
  • stickyAuth: When true, the prompt stays open and retries after a failed attempt (within system limits). When false, it closes after one failure.
  • biometricOnly: If true, only biometrics work. If false, the system can fall back to device PIN/passcode on failure. I set this to false almost always, because users deserve a proper fallback.

Unlocking stored credentials with biometrics

The real power of biometric auth is using it as a gate for stored sensitive data. In a trading app, you might unlock an API token. In a ride-hailing app, you unlock payment methods. Here's a practical pattern:

dart
class SecureCredentialStore {
  final BiometricService _biometricService;
  final FlutterSecureStorage _storage;

  SecureCredentialStore(this._biometricService, this._storage);

  Future<String?> getStoredAccessToken() async {
    // Check if we have stored credentials and biometric is available
    final hasBiometric = await _biometricService.canAuthenticateWithBiometrics();
    final hasStoredToken = await _storage.read(key: 'access_token') != null;

    if (!hasStoredToken || !hasBiometric) {
      return null; // No stored credential or no biometric available
    }

    // Require biometric to unlock
    final isAuthenticated = await _biometricService.authenticate();
    if (!isAuthenticated) {
      return null; // User failed or cancelled
    }

    // Return the unlocked credential
    return await _storage.read(key: 'access_token');
  }

  Future<void> storeAccessToken(String token) async {
    await _storage.write(key: 'access_token', value: token);
  }
}

Notice: we never store the biometric result itself. We use it only to unlock access to what's already encrypted in storage. The flutter_secure_storage package handles the encryption at rest on both platforms.

Building a login flow that respects user choice

Here's how I structure a real app's authentication screen. The user can log in with password normally, but after first login, we offer biometric as a shortcut on subsequent opens:

dart
class LoginScreen extends StatefulWidget {
  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _biometricService = BiometricService();
  final _credentialStore = SecureCredentialStore(/* ... */);
  bool _showBiometricOption = false;

  @override
  void initState() {
    super.initState();
    _checkBiometricAvailability();
  }

  Future<void> _checkBiometricAvailability() async {
    final hasBiometric = await _biometricService.canAuthenticateWithBiometrics();
    final hasStored = await _credentialStore._storage.read(key: 'access_token') != null;
    
    if (hasBiometric && hasStored) {
      setState(() => _showBiometricOption = true);
    }
  }

  Future<void> _biometricLogin() async {
    try {
      final token = await _credentialStore.getStoredAccessToken();
      if (token != null) {
        // Navigate to home
        Navigator.of(context).pushReplacementNamed('/home');
      } else {
        _showSnackbar('Biometric authentication failed. Please use password.');
      }
    } catch (e) {
      _showSnackbar('Error: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          if (_showBiometricOption)
            ElevatedButton.icon(
              onPressed: _biometricLogin,
              icon: const Icon(Icons.fingerprint),
              label: const Text('Login with biometric'),
            ),
          const SizedBox(height: 16),
          // Traditional email/password form below
          _buildPasswordForm(),
        ],
      ),
    );
  }

  Widget _buildPasswordForm() {
    // Email and password inputs, login button
    return const Placeholder();
  }

  void _showSnackbar(String message) {
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text(message)));
  }
}

This respects the user's choice: they can always fall back to password entry. The biometric option is secondary convenience, not a forced gate.

Handling platform-specific errors and edge cases

Biometric auth can fail for many reasons: user cancelled, no matching fingerprint, too many failed attempts, device is locked, system timeout. You need to handle these gracefully and not lock the user out:

dart
Future<BiometricResult> authenticateWithErrorHandling() async {
  try {
    final isAuthenticated = await _localAuth.authenticate(
      localizedReason: 'Verify your identity',
      options: const AuthenticationOptions(stickyAuth: false),
    );
    return isAuthenticated
        ? BiometricResult.success
        : BiometricResult.cancelled;
  } on PlatformException catch (e) {
    switch (e.code) {
      case 'NotAvailable':
        return BiometricResult.notAvailable;
      case 'NotEnrolled':
        return BiometricResult.notEnrolled;
      case 'LockedOut':
        return BiometricResult.lockedOut;
      case 'PermanentlyLockedOut':
        return BiometricResult.permanentlyLockedOut;
      case 'OtherOperatingSystem':
        return BiometricResult.unsupported;
      default:
        return BiometricResult.error;
    }
  }
}

enum BiometricResult {
  success,
  cancelled,
  notAvailable,
  notEnrolled,
  lockedOut,
  permanentlyLockedOut,
  unsupported,
  error,
}

The LockedOut and PermanentlyLockedOut codes are important. On both iOS and Android, after too many failed biometric attempts, the system forces the user to use their PIN/passcode before they can try biometrics again. Always let them fall back to password entry.

Respecting platform permission descriptions

Those permission strings I mentioned earlier aren't just decorative. Users read them. If your app says "We use Face ID for authentication" but then silently logs everything they do, trust evaporates. Here's the principle: be specific, be honest, and keep the permission request close to the moment of use.

On iOS, if the user denies biometric permission, you can ask again—but the system throttles repeated denials, and eventually the user gets a "don't ask again" state. On Android, the permission is requested at install time (on Android 6+), but the biometric flow still respects the user's enrollment status at runtime.

Don't ask for biometric permission at app startup just to show a fingerprint icon. Ask only when the user actually tries to set up biometric unlock, or when they're logging in and biometric is available.

Common mistakes

  • No fallback path: If you make biometric mandatory and it fails, the user is stuck. Always offer password login, PIN entry, or other recovery. I've seen apps that would only authenticate via Face ID, and a scratched camera lens meant a complete lockout.
  • Checking biometric availability once and caching it forever: Users enroll or disable biometrics mid-session. Re-check availability periodically, especially when returning from background.
  • Storing credentials in plaintext after biometric unlock: Biometric is just the gate. Keep sensitive data encrypted in flutter_secure_storage or similar. Unlocking with biometric doesn't mean you stop protecting the data.
  • Ignoring LockedOut errors: When the system says the user has failed too many times, don't retry biometrics. Offer password entry immediately or the user will get even more frustrated.
  • Overly generic permission strings: "We need biometrics" is weaker than "We use your fingerprint to authenticate you without re-entering your password." Users trust specificity.

Key takeaways

  • Use local_auth to bridge iOS LocalAuthentication and Android BiometricPrompt into one consistent API.
  • Always check getAvailableBiometrics() before offering biometric options—not all users have enrolled biometrics.
  • Build biometric authentication as a convenience layer over password login, not a replacement. Provide a fallback path every time.
  • Pair biometric unlock with secure storage (flutter_secure_storage) to gate access to sensitive credentials, not just login state.
  • Handle all PlatformException error codes gracefully—especially LockedOut and NotEnrolled—and guide users to alternate authentication methods.
  • Write honest, specific permission descriptions and request biometric permission only when the user is actively setting it up or using it.
  • Respect the user's choice: if they cancel biometric, fall back immediately. Don't retry or nag them into it.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles