Skip to content
Blog

Biometric Authentication in Your App

Integrate fingerprint and face unlock into your app with Android BiometricPrompt, iOS LocalAuthentication, and Flutter local_auth, with proper failure handling and fallback flows.

Published on August 18, 2026

AI Assistant

Biometric Authentication in Your App

Passwords are the worst authentication mechanism we keep shipping. They get leaked in breaches, recycled across services, and — when you enforce any reasonable complexity — forgotten within a week. Users already carry a better credential in their pocket: the fingerprint or face enrolled on their own device. Wrapping your login flow in a biometric prompt is one of the fastest, highest-impact security upgrades a mobile app can ship.

In this tutorial, you will learn how to integrate biometric authentication into a native Android app with the androidx.biometric BiometricPrompt, a native iOS app with the LocalAuthentication framework, and a cross-platform Flutter app with the local_auth package. You will also learn how to detect when biometrics are unavailable, handle every failure mode from canceled prompts to permanent lockout, and design a sane password fallback so you never lock a user out of their account.

Key technologies: androidx.biometric BiometricPrompt, BiometricManager, iOS LAContext and evaluatePolicy, Flutter local_auth, Keychain access control.

Prerequisites

  • For Android: Android Studio, a project with minSdkVersion 21 or higher, and a device or emulator with an enrolled fingerprint or face.
  • For iOS: Xcode, a physical iOS device (the simulator cannot reliably simulate Face ID for evaluatePolicy), and the NSFaceIDUsageDescription key planned for Info.plist.
  • For Flutter: the Flutter SDK and the local_auth package added to pubspec.yaml.
  • A basic grasp of async callbacks on each platform (Kotlin lambdas, Swift completion handlers, Dart Futures).

How Biometric Authentication Works

Before touching code, understand what you are being asked to trust. On both platforms the biometric sensor data never reaches your app. Android’s BiometricPrompt talks to a Trusted Execution Environment, and iOS’s LocalAuthentication delegates matching to the Secure Enclave — a coprocessor isolated from the CPU and the operating system. Your app receives one of a handful of outcomes: success, failure (a valid biometric that was not recognized), or an error carrying a code that explains why (canceled, locked out, no biometrics enrolled, and so on). Everything you write is a state machine over those outcomes.

That asymmetry is the core mental model: you never verify a fingerprint yourself; you ask the platform to do it and handle the verdict.

Checking Availability First

Every platform lets you ask, before showing a prompt, whether biometrics are even possible. The check is a hint, not a guarantee — device state can change between the check and the prompt — but it lets you skip showing a useless dialog.

On Android, BiometricManager.canAuthenticate() returns one of several results:

val manager = BiometricManager.from(context)
when (manager.canAuthenticate(
    BiometricManager.Authenticators.BIOMETRIC_WEAK or
    BiometricManager.Authenticators.DEVICE_CREDENTIAL
)) {
    BiometricManager.BIOMETRIC_SUCCESS -> promptUser()
    BiometricManager.BIOMETRIC_ERROR_NO_ENROLLMENT -> promptEnroll()
    BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> promptEnroll()
    else -> fallbackToPassword()
}

Two things matter here. First, the DEVICE_CREDENTIAL authenticator is included so a user with a passcode but no biometrics still gets an option. Second, BIOMETRIC_SUCCESS is not a green light to skip your own auth: the user can still cancel, fail, or get locked out.

On iOS, the equivalent is canEvaluatePolicy on an LAContext:

let context = LAContext()
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
    // proceed to authentication
} else {
    // read error.domain and error.code to decide why
    fallbackToPassword()
}

On Flutter, local_auth exposes the same idea through canCheckBiometrics, isDeviceSupported, and getAvailableBiometrics:

final auth = LocalAuthentication();
final isSupported = await auth.isDeviceSupported();
final canCheck = await auth.canCheckBiometrics();
final enrolled = await auth.getEnrolledBiometrics();
if (isSupported && canCheck && enrolled.isNotEmpty) {
  promptUser();
} else {
  fallbackToPassword();
}

Note the deliberate three-part check in Flutter: the device may support biometrics in principle, the sensor may be present, and yet the user may not have enrolled anything.

Android: BiometricPrompt with Full Callback Handling

Now the real work. On Android you build a PromptInfo describing the dialog, construct a BiometricPrompt bound to your FragmentActivity or Fragment, and pass an AuthenticationCallback that reacts to every outcome.

val promptInfo = BiometricPrompt.PromptInfo.Builder()
    .setTitle("Unlock your account")
    .setSubtitle("Sign in with your fingerprint")
    .setNegativeButtonText("Use password")
    .setAllowedAuthenticators(
        BiometricManager.Authenticators.BIOMETRIC_WEAK or
        BiometricManager.Authenticators.DEVICE_CREDENTIAL
    )
    .build()

val prompt = BiometricPrompt(activity, executor) { result ->
    when (result.authenticationType) {
        BiometricPrompt.AUTHENTICATION_TYPE_BIOMETRIC -> onAuthenticated()
        BiometricPrompt.AUTHENTICATION_TYPE_DEVICE_CREDENTIAL -> onAuthenticated()
    }
}

The key decision is setAllowedAuthenticators. If you want a hard biometric gate, pass only BIOMETRIC_WEAK and keep DEVICE_CREDENTIAL out. If you prefer availability — a passcode fallback built into the same dialog — include both, exactly as shown.

The callback that does the heavy lifting is where authentication failures are handled:

private val callback = object : BiometricPrompt.AuthenticationCallback() {
    override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
        when (errorCode) {
            BiometricPrompt.ERROR_NEGATIVE_BUTTON -> fallbackToPassword()
            BiometricPrompt.ERROR_USER_CANCELED -> doNothing()
            BiometricPrompt.ERROR_CANCELED -> doNothing()
            BiometricPrompt.ERROR_LOCKOUT,
            BiometricPrompt.ERROR_LOCKOUT_PERMANENT ->
                showErrorAndFallback("Too many attempts. Use your password.")
            BiometricPrompt.ERROR_NO_BIOMETRICS -> promptEnroll()
            else -> showError(errString)
        }
    }

    override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
        onAuthenticated()
    }

    override fun onAuthenticationFailed() {
        showSnackbar("Fingerprint not recognized. Try again.")
    }

    override fun onAuthenticationHelp(helpCode: Int, helpString: CharSequence) {
        showSnackbar(helpString)
    }
}

prompt.authenticate(promptInfo)

Read the error table carefully because each branch is a different user story. ERROR_USER_CANCELED and ERROR_CANCELED are benign — the user walked away, so do not punish them. ERROR_NEGATIVE_BUTTON fires when the user taps the “Use password” button, which is your explicit fallback trigger. ERROR_LOCKOUT and ERROR_LOCKOUT_PERMANENT arrive after repeated failures and require a device passcode to reset, so your app must route those users to a password screen immediately. And onAuthenticationFailed is not an error — the dialog stays open and the user may simply try again.

For maximum security, pass a CryptoObject to authenticate() so the success callback delivers a key usable to decrypt a vault or sign a request. That ties the biometric check to a concrete cryptographic operation instead of just a boolean.

iOS: LAContext and evaluatePolicy

On iOS the flow mirrors Android but with a single async completion handler. Configure the context, ask for the policy, and interpret the LAError in the completion block.

import LocalAuthentication

func authenticateUser() {
    let context = LAContext()
    context.localizedCancelTitle = "Use Password"
    context.localizedFallbackTitle = "Use Passcode"
    let reason = "Authenticate to access your secure data"

    guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) else {
        fallbackToPassword()
        return
    }

    context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, error in
        DispatchQueue.main.async {
            if success {
                self.onAuthenticated()
            } else if let error = error as? LAError {
                switch error.code {
                case .authenticationFailed:
                    self.showError("Biometric not recognized")
                case .userCancel, .appCancel:
                    break
                case .userFallback, .biometryLockout:
                    self.fallbackToPassword()
                case .biometryNotEnrolled:
                    self.promptEnroll()
                case .biometryNotAvailable:
                    self.fallbackToPassword()
                default:
                    self.showError(error.localizedDescription)
                }
            }
        }
    }
}

Three policies are worth knowing. .deviceOwnerAuthentication allows biometrics or the device passcode — the sensible default for most apps. .deviceOwnerAuthenticationWithBiometrics requires a biometric and fails on passcode use. The third, .deviceOwnerAuthenticationWithWatch, is for Apple Watch fallback and rarely needed. Prefer .deviceOwnerAuthentication unless you have a hard security requirement.

A few iOS-specific sharp edges. First, Face ID requires the NSFaceIDUsageDescription key in Info.plist, or the system kills your prompt with biometryNotAvailable. Second, create a fresh LAContext per attempt; reusing a context after a failure, especially after appCancel, returns errors immediately. Third, always hop back to the main queue before touching UI — evaluatePolicy invokes its completion on a background thread.

Flutter: One API for Both Platforms

If you ship with Flutter, local_auth wraps both platforms so a single authenticate() call handles everything. The plugin follows the same three-step shape: check, prompt, handle the outcome.

Future<AuthResult> authenticate() async {
  final auth = LocalAuthentication();

  if (!await auth.isDeviceSupported()) {
    return AuthResult.unavailable();
  }

  try {
    final ok = await auth.authenticate(
      localizedReason: 'Sign in with your fingerprint or face',
      options: AuthenticationOptions(
        stickyAuth: true,
        useErrorDialogs: true,
        biometricOnly: false,
      ),
    );
    return ok ? AuthResult.success() : AuthResult.failed();
  } on PlatformException catch (e) {
    if (e.code == 'LockedOut' || e.code == 'PermanentlyLockedOut') {
      return AuthResult.lockedOut();
    }
    if (e.code == 'NotAvailable' || e.code == 'NotEnrolled') {
      return AuthResult.unavailable();
    }
    return AuthResult.error(e.code);
  }
}

Two options deserve attention. stickyAuth: true keeps the prompt alive when the app goes to the background and returns (for example, an incoming call or a quick app switch), so a half-finished attempt does not silently die. biometricOnly: false mirrors Android’s DEVICE_CREDENTIAL inclusion, allowing the passcode as a fallback. Both reflect the same availability-first philosophy as the native examples.

Designing the Fallback Flow

Biometrics are a convenience layer on top of real authentication, never a replacement for it. Any user can lose access to a biometric: a fresh device, a deleted enrollment, a failed sensor, or a permanent lockout. Your fallback is what separates a polished app from one that bricks users out of their data.

Follow these rules across all three platforms:

  1. Always offer a password or passcode path. The platform-level fallback (Android DEVICE_CREDENTIAL, iOS passcode) covers the “forgot fingerprint” case, but your own app password should still exist for password resets and account recovery.
  2. Never store the biometric result. Authenticate on demand; do not cache success and trust it for hours. If you need persistent access, store the secret in the platform keychain and gate it with biometric access control (Android Keystore with setUserAuthenticationRequired, iOS Keychain with .biometryCurrentSet).
  3. Be explicit about lockout. After a permanent lockout the only way back in is a device passcode or your password — tell the user exactly that instead of showing a generic error.
  4. Distinguish cancel from failure. A dismissed prompt is not a failed login. Track cancellation separately so you do not trigger rate limiting or analytics on normal user behavior.
  5. Check biometryType before rendering UI. Show a fingerprint or face icon and matching copy (“Sign in with Face ID”) instead of a generic lock icon.

Putting It All Together

The full flow, independent of platform, is: check availability, if unavailable route to the password screen; if available show the prompt; on success call your existing token-exchange endpoint; on cancel or fallback trigger the password screen; on lockout show a clear message and force the password path. Here is that skeleton in Kotlin, which maps one-to-one onto the Swift and Dart versions:

class LoginActivity : FragmentActivity() {

    private fun onLoginClicked() {
        val canUse = BiometricManager.from(this)
            .canAuthenticate(
                BiometricManager.Authenticators.BIOMETRIC_WEAK or
                BiometricManager.Authenticators.DEVICE_CREDENTIAL
            ) == BiometricManager.BIOMETRIC_SUCCESS

        if (canUse) {
            showBiometricPrompt()
        } else {
            showPasswordScreen()
        }
    }

    private fun onAuthenticated() {
        startActivity(Intent(this, MainActivity::class.java))
        finish()
    }

    private fun fallbackToPassword() = showPasswordScreen()
}

Whatever language you write, keep the logic in one small, well-named controller and inject the prompt as an interface. That makes the biometric gate trivially testable — mock the interface and assert that success, cancel, and lockout each take the correct path.

Conclusion & Next Steps

You now have working biometric authentication on Android, iOS, and Flutter, plus a failure model that treats canceled, failed, and locked-out users correctly and always preserves a password escape hatch. The pattern is identical everywhere: check availability, prompt, interpret the verdict, fall back gracefully.

Where to go next:

  • Add CryptoObject (Android) or Keychain access control (iOS) so the biometric gates a real key rather than a boolean.
  • Add app-level password reset that does not depend on biometric state.
  • Test the lockout path on a real device before release; it is the failure mode users hit at the worst moments.
  • Consider LARight and LAEnvironment on iOS 16+ for richer persistent authorization, and watch the androidx.biometric changelog as new error codes appear.

Biometrics will not replace passwords tomorrow, but they remove them from the critical path. Ship the convenience, keep the fallback, and your users will thank you.