implementing Face ID and fingerprint the right way
Biometric authentication replaces tedious PIN entry with a glance or a touch, but it requires a clear understanding of the underlying security model. Face ID on iOS and fingerprint prompts on Android never unlock raw biometric data, they unlock a cryptographic key stored locally on the device. This article shows how to implement biometric authentication in React Native with expo-local-authentication and react-native-biometrics, combine it with real server-side verification, and back it with sensible fallbacks.
Table of Contents
- 1. Why biometric authentication makes sense in apps
- 2. Libraries at a glance
- 3. Setting up Face ID on iOS
- 4. Fingerprint and BiometricPrompt on Android
- 5. Implementation with react-native-biometrics
- 6. Fallback strategies
- 7. Truly understanding the security model
- 8. UX best practices
- 9. Biometric approaches compared
- 10. Summary
- 11. FAQ
1. Why biometric authentication makes sense in apps
Biometric authentication solves a very concrete UX problem: users dislike typing an app PIN or password multiple times a day, especially in apps that get opened and closed frequently for just a few seconds. A glance for Face ID or a touch on the fingerprint sensor takes a fraction of a second and feels more natural to most users than typing a code. That is exactly why biometric authentication is now standard in banking apps, password managers, and any application that wants to offer fast yet protected access to sensitive content.
It is important not to misunderstand the security model here. Biometric authentication never transmits fingerprint or face data anywhere, not to a server and not even to the app itself. Instead, the operating system checks locally on the device whether the presented biometric data matches the enrolled reference, and on success it unlocks a cryptographic key that lives inside a secured hardware component. The app never sees the raw face or fingerprint data, only the result: success or failure, or a signature produced with the unlocked key.
This separation is exactly why biometric authentication can be trusted as a security mechanism in the first place. An app that integrates Face ID or fingerprint checks never has to process, store, or transmit biometric data to a backend itself, which significantly simplifies privacy compliance. At the same time, this also means that biometric authentication alone does not automatically prove identity to a server, a point covered in detail in section 7.
2. Libraries at a glance
Two libraries have become the standard for biometric authentication in React Native, each covering a different set of requirements. expo-local-authentication offers a lean API for pure device-level authentication: it checks whether Face ID or fingerprint hardware is available, shows the system's native prompt, and returns a simple success or failure result. For use cases like locking a section of the app or reconfirming before a sensitive in-app action, that is entirely sufficient.
react-native-biometrics goes a step further and can additionally generate asymmetric key pairs whose private key never leaves the secured hardware. This makes it possible to produce challenge-response signatures that a server can actually verify cryptographically. That capability becomes essential the moment a backend needs to genuinely trust a user's biometric confirmation, for example during passwordless login or when approving a transaction.
The rule of thumb: if biometric authentication is only needed as a local access gate inside the app, expo-local-authentication is enough and saves extra complexity. If a server-side instance needs to be convinced that the authorized user, and not just anyone holding the unlocked device, actually performed the action, you need the public-key capabilities of react-native-biometrics or a custom native module with equivalent functionality.
// LocalAuthGate.js — pure device-level check with expo-local-authentication
import * as LocalAuthentication from 'expo-local-authentication';
/**
* Runs a local biometric authentication prompt and returns
* whether the device owner successfully confirmed their identity.
* This does NOT prove anything to a remote server on its own.
*/
export async function requestBiometricUnlock() {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!hasHardware || !isEnrolled) {
// No biometric sensor, or none configured on this device
return { success: false, reason: 'unavailable' };
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Unlock with Face ID or fingerprint',
fallbackLabel: 'Use device passcode',
cancelLabel: 'Cancel',
disableDeviceFallback: false, // always allow passcode fallback
});
return result.success
? { success: true }
: { success: false, reason: result.error };
}
3. Setting up Face ID on iOS
On iOS, biometric authentication runs through Apple's LocalAuthentication framework, which abstracts both Face ID and the older Touch ID sensor behind the same API. Before any prompt can even appear, the app must declare NSFaceIDUsageDescription in Info.plist, an explanation text shown to the user describing what the app wants to use Face ID for. Without this entry, the app crashes silently the first time it tries to use Face ID, an error that is easy to miss during development because the Touch ID path on older test devices does not require this declaration at all.
The actual flow starts with LAContext.canEvaluatePolicy(), which checks whether the chosen policy can be evaluated at all before the real prompt is triggered. This check covers two distinct cases: whether matching hardware is present, and whether the user has enrolled any biometrics at all. A device with a working Face ID sensor but no enrolled face deliberately returns a negative result here, so the app can fall back to an alternative such as the device passcode in time.
Only once canEvaluatePolicy() succeeds should you call evaluatePolicy(), which shows the actual Face ID or Touch ID prompt and returns the result asynchronously. In React Native, both expo-local-authentication and react-native-biometrics handle these two steps internally, so you rarely need to touch LAContext directly in Swift, except when building a custom native module or debugging an issue that is not visible at the JavaScript layer.
// BiometricAuthBridge.swift — minimal LAContext usage for Face ID / Touch ID
import LocalAuthentication
func evaluateBiometricPolicy(completion: @escaping (Bool, String?) -> Void) {
let context = LAContext()
var error: NSError?
// Step 1: check hardware availability and enrollment BEFORE prompting
let policy = LAPolicy.deviceOwnerAuthenticationWithBiometrics
guard context.canEvaluatePolicy(policy, error: &error) else {
completion(false, error?.localizedDescription ?? "biometry_unavailable")
return
}
// Step 2: show the actual Face ID / Touch ID prompt
let reason = "Authenticate with Face ID or Touch ID"
context.evaluatePolicy(policy, localizedReason: reason) { success, evalError in
DispatchQueue.main.async {
completion(success, evalError?.localizedDescription)
}
}
}
In the Expo managed workflow, NSFaceIDUsageDescription cannot be maintained in a custom Info.plist directly, it is set through app.json and a config plugin instead. On the next eas build, the entry is automatically written into the generated native project files, together with the required Android permissions, without touching native project files by hand.
{
"expo": {
"name": "mironsoft-demo",
"slug": "mironsoft-demo",
"plugins": [
[
"expo-local-authentication",
{
"faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID for biometric authentication."
}
]
],
"ios": {
"infoPlist": {
"NSFaceIDUsageDescription": "This app uses Face ID to securely unlock your account without typing a password."
}
},
"android": {
"permissions": ["USE_BIOMETRIC", "USE_FINGERPRINT"]
}
}
}
4. Fingerprint and BiometricPrompt on Android
On Android, the modern API for biometric authentication is the BiometricPrompt class, which handles fingerprint, face recognition, and iris scanning uniformly depending on the device. It requires the USE_BIOMETRIC entry as a permission in AndroidManifest.xml, a so-called normal permission that is granted automatically at install time and does not require an explicit runtime dialog. That makes the entry barrier noticeably simpler than dangerous permissions such as camera or location.
Before showing the actual prompt, you should always call BiometricManager.canAuthenticate(), which, analogous to canEvaluatePolicy() on iOS, checks whether matching hardware is present and at least one biometric is enrolled. The return value distinguishes several error states in fine detail: no hardware, hardware temporarily unavailable, or hardware present but no biometrics enrolled. This distinction lets you show the user an appropriate error message instead of a generic "fingerprint unavailable".
It is important to understand the difference to the older FingerprintManager API, which is now considered deprecated. The old API supported only fingerprints and offered no unified UI, every manufacturer showed its own dialog with a different design. BiometricPrompt, by contrast, provides a system-supplied, consistent interface across all supported biometric types and has been the recommended path since Android 9. New apps should use BiometricPrompt exclusively, the old API only survives for compatibility reasons in older codebases.
// BiometricAuthHelper.kt — minimal BiometricPrompt implementation
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import androidx.core.content.ContextCompat
fun checkBiometricAvailability(activity: FragmentActivity): Int {
val biometricManager = BiometricManager.from(activity)
return biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG
)
// Compare against BIOMETRIC_SUCCESS, BIOMETRIC_ERROR_NO_HARDWARE,
// BIOMETRIC_ERROR_HW_UNAVAILABLE, BIOMETRIC_ERROR_NONE_ENROLLED
}
fun showBiometricPrompt(
activity: FragmentActivity,
onSuccess: () -> Unit,
onError: (String) -> Unit,
) {
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errString.toString())
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric authentication")
.setSubtitle("Confirm with fingerprint or face")
.setNegativeButtonText("Cancel")
.build()
prompt.authenticate(promptInfo)
}
5. Implementation with react-native-biometrics
Once biometric authentication needs to be provable to a server rather than just checked locally, react-native-biometrics comes into play. The central call, createKeys(), generates an asymmetric key pair directly on the device. The private key stays inside the Secure Enclave on iOS or the Android Keystore, and it never leaves this secured hardware component, neither as a file nor in the app's memory. Only the public key is transmitted to the server, where it gets associated with the corresponding user account.
For the actual authentication, the app first requests a random challenge from the server. It then calls createSignature(), signing exactly that challenge. The operating system automatically shows the Face ID or fingerprint prompt, and only on a successful biometric confirmation is the private key briefly released to perform the signing. The resulting signature is sent back to the server, which verifies it against the public key stored earlier. If this verification fails, either the biometric check did not succeed, or the client has been tampered with.
For simpler cases where no server-side verification is needed, the same library also offers simplePrompt(), which behaves functionally like expo-local-authentication: a plain yes-or-no prompt without any cryptographic proof. Having both modes available in one library makes react-native-biometrics a pragmatic choice for an app that might grow from purely local confirmation into real server-side verification over time.
// BiometricChallenge.js — server-verifiable biometric authentication
import ReactNativeBiometrics from 'react-native-biometrics';
const rnBiometrics = new ReactNativeBiometrics({
allowDeviceCredentials: true, // fall back to device passcode/PIN
});
/**
* Generates a device-bound key pair once (e.g. during onboarding or login).
* The private key never leaves the Secure Enclave / Android Keystore.
*/
export async function enrollBiometricKey(userId) {
const { keysExist } = await rnBiometrics.biometricKeysExist();
if (keysExist) return true;
const { publicKey } = await rnBiometrics.createKeys();
// Send only the public key to the backend, tied to this userId
await fetch('https://api.mironsoft.de/v1/biometric-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, publicKey }),
});
return true;
}
/**
* Performs a real challenge-response biometric authentication
* that the server can cryptographically verify.
*/
export async function authenticateWithBiometrics(userId) {
const challengeRes = await fetch(
`https://api.mironsoft.de/v1/biometric-challenge?userId=${userId}`
);
const { challenge } = await challengeRes.json();
const { success, signature } = await rnBiometrics.createSignature({
promptMessage: 'Confirm your identity with biometrics',
payload: challenge,
});
if (!success) {
return { authenticated: false, reason: 'biometric_prompt_failed' };
}
const verifyRes = await fetch('https://api.mironsoft.de/v1/biometric-verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, challenge, signature }),
});
const { verified } = await verifyRes.json();
return { authenticated: verified };
}
6. Fallback strategies
No biometric system works reliably in every situation for every user, which is why fallback strategies must be a fixed part of every biometric authentication implementation. Face ID can fail in unfavorable light or with a partially covered face, a fingerprint sensor can produce false negatives with wet or dirty fingers, and some users choose not to enroll any biometrics on their device for personal reasons. An app that completely blocks access in these cases loses users at a point that is technically easy to avoid.
Both iOS and Android provide a built-in fallback to the device passcode or PIN, which can be enabled through parameters such as disableDeviceFallback or allowDeviceCredentials. This fallback runs entirely at the operating system level, so the app does not need to build its own PIN entry screen, it simply receives the result of the system dialog. It is important to actively allow this fallback rather than disabling it for convenience, otherwise users without enrolled biometrics get locked out entirely.
After several consecutive failed attempts, both iOS and Android temporarily lock out biometric input and then require the device code, a lockout behavior that prevents brute-force attacks against the biometric sensor. An app should recognize this state and clearly communicate to the user why only the passcode dialog is appearing, rather than showing a seemingly random error. As a general rule, biometric authentication should never be the only option with no fallback whatsoever, it should always remain a faster addition to a working password or PIN path.
7. Truly understanding the security model
A common misunderstanding around biometric authentication is assuming that a successful Face ID or fingerprint prompt automatically proves a specific user's identity to a server. In reality, a simple prompt through expo-local-authentication or simplePrompt() only confirms so-called "device owner authentication": some person sitting at the unlocked device, whose biometrics are enrolled in the system, confirmed the prompt. For purely local access control inside an app, that is entirely sufficient, but it is not a statement a backend can rely on.
Real identity verification against a server requires the public-key challenge-response authentication described in section 5. The crucial difference: only when the server can cryptographically verify a client-signed, server-generated random challenge against the public key stored earlier does it have solid proof that the private key of this specific device was actually used, released by a successful biometric check. A plain "true" or "false" from a local prompt, on the other hand, can be forged arbitrarily once it is sent to a server unverified.
For use cases such as passwordless login, approving payments, or signing legally relevant transactions, only the challenge-response approach is suitable. For less critical cases, such as re-locking an already logged-in app area after a pause, the simpler device-owner variant is enough, since no new statement needs to be made to a server anyway, only the already authenticated state of the app is being protected.
8. UX best practices
The right timing for a biometric authentication prompt is decisive for user acceptance. Sensible moments include app start after a full cold start, sensitive actions such as viewing account details or confirming a payment, and returning from the background after a reasonable period of inactivity. A prompt on every single screen navigation, on the other hand, feels intrusive and makes users perceive the app as annoying, even if the underlying security is technically correct.
Accessibility deserves particular attention with biometric authentication. Not every user can or wants to enroll biometrics, whether for motor, health, or purely personal reasons. An app must therefore always offer a fully equivalent path without biometrics, not just as a technical fallback, but as an equally prominent, clearly discoverable option in the settings. If biometrics is presented as the only visible login path and the password path is hidden away, it effectively creates an access barrier for part of the user base.
Error messages should never pass through cryptic system text unchanged. Instead of raw messages like "Authentication error -7" from LAContext or BiometricPrompt, the app should translate the known error codes into clear, actionable text: "Fingerprint not recognized, please try again" or "Face ID is not set up on this device, please use your password instead." This translation noticeably reduces support requests and makes biometric authentication feel understandable to users instead of frustrating.
9. Biometric approaches compared
Choosing the right solution for biometric authentication in React Native largely depends on whether server-side verification is ultimately required or a local confirmation is enough. The following overview compares the three common approaches.
| Approach | Server verification possible | Setup effort | Use case |
|---|---|---|---|
| expo-local-authentication | No, device-owner check only | Low | App lock, reconfirming a sensitive action |
| react-native-biometrics | Yes, public-key challenge-response | Medium | Passwordless login, payment approval |
| Custom native module | Yes, full control | High | Special requirements, e.g. custom crypto parameters |
In practice, expo-local-authentication is enough for the vast majority of cases with purely local access control and should be preferred there, since it brings fewer native dependencies. But the moment a backend instance genuinely needs to trust a biometric confirmation, there is no way around react-native-biometrics or a comparable custom native module, since only the public-key approach delivers a genuinely verifiable cryptographic statement.
Mironsoft
React Native development for iOS and Android
Biometric authentication your server can actually trust?
We build Face ID and fingerprint flows for your React Native app, including real challenge-response verification against your backend, clean fallback strategies, and a UX that never locks users out.
Face ID & fingerprint
Clean setup on iOS and Android, including Info.plist and AndroidManifest.xml
Server verification
Public-key challenge-response with react-native-biometrics against your backend
UX & fallbacks
Accessible fallback paths and clear error messages instead of raw system text
10. Summary
Biometric authentication in React Native can be covered with two established libraries: expo-local-authentication for pure, local device authentication, and react-native-biometrics the moment a cryptographically solid server-side verification is needed. Face ID on iOS strictly requires the NSFaceIDUsageDescription entry and a check through LAContext.canEvaluatePolicy() before the actual prompt. Fingerprint and face recognition on Android run through the modern BiometricPrompt API, backed by the USE_BIOMETRIC permission and BiometricManager.canAuthenticate().
The most important conceptual point remains the distinction between a simple device-owner confirmation and a real public-key challenge-response authentication. Only the latter gives a server genuinely verifiable proof that a specific device's private key was used after a successful biometric check. Fallback strategies to the device passcode and an accessible, clearly communicated UX round out a robust implementation of biometric authentication, without excluding users who have not enrolled any biometrics.
React Native Biometric Authentication — the key facts at a glance
Libraries
expo-local-authentication for local checks, react-native-biometrics for server-verifiable challenge-response signatures.
Platform setup
NSFaceIDUsageDescription (iOS) and the USE_BIOMETRIC permission with BiometricPrompt (Android) are mandatory.
Security model
A device-owner check proves nothing to a server. Only public-key challenge-response does.
Fallbacks & UX
Always allow the device passcode as fallback, recognize lockout behavior, show understandable error messages.