React Native Biometric Auth: A Practical 2026 Guide
Learn how to add react native biometric authentication in Expo apps. Covers Face ID, Touch ID, Android biometrics, secure storage, and edge cases.

Maya was three days from shipping her neobank MVP when users asked for Face ID login. The prompt itself took minutes to trigger. The dangerous part was everything after it. Her first implementation returned success: true, read a stored token, and opened the account screen. A modified app could potentially bypass that JavaScript check, and the server had no proof that a real device credential had granted access.
That's the production question behind React Native biometric authentication. Not “How do I show Face ID?” but “What exactly does biometric success authorize, what happens when the device changes, and how does the backend reject replayed or revoked access?” A local prompt can improve convenience, but it doesn't automatically create a secure login. The distinction is explained clearly in this guide to what biometric authentication means.
This guide uses expo-local-authentication for the prompt layer, then separates that convenience layer from secure storage, cryptographic key handling, backend sessions, recovery, and passkeys.
Table of Contents
- Why Biometric Auth Matters for React Native Apps
- Prerequisites and Project Setup
- Building the Core Biometric Prompt
- Wiring Biometrics to a Real Backend Session
- Face ID Touch ID and Android Differences Compared
- Handling Edge Cases Without Breaking Trust
- Where to Go Next With Passkeys and AppLighter
Why Biometric Auth Matters for React Native Apps
The appeal is obvious. Users can access a mobile app without typing a password, and the operating system keeps the underlying biometric template on the device. By 2024, Mercator forecast that 66% of smartphone owners would use biometrics for authentication, compared with 41% at the time of the forecast and 27% in 2019 (Mercator forecast via PaymentsJournal). Fingerprint readers were expected to remain the leading option, while facial recognition was projected to approach 30% of biometric authentication methods and voice recognition 20%.
That adoption changes the baseline UX, but it doesn't remove the security design. Juniper Research projected more than 770 million biometric authentication app downloads per year by 2019, up from 6 million in 2015, and said the growth would dramatically reduce dependence on alphanumeric passwords in mobile phone markets (Juniper Research). A familiar prompt is now expected. A trustworthy authorization flow still has to be designed.
Maya's risky version had three separate weaknesses:
- A local boolean gate: The app treated a successful prompt as proof of authorization, even though the server never verified anything.
- A static token handoff: If the prompt unlocked a long-lived token, an attacker who extracted or replayed that token could avoid the biometric step.
- A weak recovery boundary: Logout was the only obvious barrier after a phone was lost or a biometric enrollment changed.
Practical rule: A biometric prompt should unlock a carefully controlled credential or authorize a fresh server challenge. It shouldn't be your entire authentication system.
For a low-risk utility app, an OS prompt followed by a securely stored refresh token may be an acceptable convenience feature. A neobank, healthcare product, or admin console needs stronger separation. The app must know whether hardware exists, whether a biometric is enrolled, whether the OS has locked the user out, and whether a successful local event can produce a backend-approved session.
The rest of the implementation follows that boundary. Native configuration makes the prompt legal and usable. A typed hook keeps cancellation and lockout explicit. Secure storage protects local secrets. A challenge-response design gives the server something it can independently verify.
Prerequisites and Project Setup
Start with an Expo project that uses a development build rather than relying on Expo Go for final biometric behavior. Install the native packages with Expo's version resolver:
npx expo install expo-local-authentication expo-secure-store
expo-local-authentication owns the user-facing system prompt. expo-secure-store gives the app access to protected platform storage, with iOS Keychain and Android Keystore behavior underneath. If your project later needs controls that SecureStore doesn't expose, keep react-native-keychain as the bare React Native fallback, but don't add both libraries casually. Decide which storage abstraction owns each credential.
Add platform configuration to app.json:
{
"expo": {
"ios": {
"infoPlist": {
"NSFaceIDUsageDescription": "Use Face ID to unlock your account securely."
}
},
"android": {
"permissions": [
"android.permission.USE_BIOMETRIC",
"android.permission.USE_FINGERPRINT"
]
}
}
}
The iOS explanation must describe the user benefit. Generic permission copy creates review risk and makes the prompt feel suspicious. Android permission declarations should match the native capability your build is requesting.
Screenshot from https://example.com/screenshots/expo-app-json-biometric-config.png
Build and verify the native layer
Expo Go can't run Face ID. Create a development build and install it on a real device or simulator configured for biometrics:
eas build --profile development
Before writing prompt logic, add a one-line health check:
const biometricModuleReady =
typeof LocalAuthentication.authenticateAsync === "function";
Treat a false result as a build or linking failure, not as a user authentication failure. Then check actual device state:
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
hasHardwareAsync() answers whether the device exposes biometric hardware. isEnrolledAsync() answers whether the user has configured a usable biometric. Those are different states, and your UI should distinguish them. A device may support biometrics while having no face or fingerprint enrolled, or the user may have disabled the feature in Settings.
Don't store a password or refresh token in ordinary async storage. If SecureStore isn't suitable for your native workflow, use Keychain or Keystore-backed storage through react-native-keychain, then test reinstall, lockout, and enrollment-change behavior on both platforms.
Building the Core Biometric Prompt
A prompt wrapper should return business states, not force every screen to interpret a loosely typed result object. The following union makes cancellation, lockout, missing enrollment, and success visible to TypeScript:
import * as LocalAuthentication from "expo-local-authentication";
export type BiometricResult =
| { status: "authenticated" }
| { status: "cancelled" }
| { status: "locked-out"; message: string }
| { status: "not-enrolled"; message: string }
| { status: "unavailable"; message: string }
| { status: "failed"; message: string };
export function mapAuthResult(
result: LocalAuthentication.LocalAuthenticationResult
): BiometricResult {
if (result.success) return { status: "authenticated" };
const error = "error" in result ? result.error : undefined;
if (error === "user_cancel" || error === "system_cancel") {
return { status: "cancelled" };
}
if (
error === "lockout" ||
error === "timeout" ||
error === "too_many_attempts"
) {
return {
status: "locked-out",
message: "Biometric unlock is temporarily unavailable. Use your password."
};
}
return {
status: "failed",
message: "We couldn't verify you. Try again or use your password."
};
}
The exact error names can vary by native version, so log unknown values during development and keep the default branch conservative. A failed or cancelled prompt must never become an authenticated state.
A reusable hook
import { useCallback, useState } from "react";
import * as LocalAuthentication from "expo-local-authentication";
export function useBiometricAuth() {
const [pending, setPending] = useState(false);
const [lastResult, setLastResult] = useState<BiometricResult | null>(null);
const authenticate = useCallback(async (): Promise<BiometricResult> => {
if (pending) return { status: "failed", message: "Authentication is busy." };
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!hasHardware || !isEnrolled) {
const result: BiometricResult = {
status: "not-enrolled",
message: "Set up Face ID or a fingerprint in device settings first."
};
setLastResult(result);
return result;
}
const types = await LocalAuthentication.supportedAuthenticationTypesAsync();
const isFace = types.includes(
LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION
);
setPending(true);
try {
const raw = await LocalAuthentication.authenticateAsync({
promptMessage: isFace ? "Unlock with Face ID" : "Unlock with fingerprint",
fallbackLabel: "Use password",
disableDeviceFallback: false
});
const result = mapAuthResult(raw);
setLastResult(result);
return result;
} finally {
setPending(false);
}
}, [pending]);
return { authenticate, pending, lastResult };
}
Keep disableDeviceFallback deliberate. For a sensitive action, you may require the biometric itself. For routine access, allowing the device credential can reduce dead ends, but the backend should still apply its own authorization rules. A BiometricLoginButton should disable itself while pending, show an inline error chip for recoverable failures, and expose a password route beside it.
A short cooldown after three failed attempts is a reasonable product rule, but the operating system remains the authority for lockout. Store attempt timestamps in memory or protected local state, and route the user to password recovery rather than repeatedly reopening the prompt. If you're comparing implementation patterns or indie authentication tools, this one-time listing for indie tools is a useful place to review alternatives without confusing a prompt library with a complete auth system.
Wiring Biometrics to a Real Backend Session
A successful local prompt should authorize a cryptographic operation, not merely flip isLoggedIn to true. The stronger pattern is to generate a device keypair, register the public key with your API, request a fresh challenge at login, and have the device sign that challenge only after biometric approval.
The private key must remain protected by platform storage or a native hardware-backed key implementation. The public key can be registered with the account and associated with a device record. The server then verifies the signature before issuing or refreshing a JWT.
An infographic illustrating the five-step process of integrating biometric authentication with a secure backend session.
Separate enrollment from authentication
Expo's local-authentication package gives you a prompt, not a complete native key-signing API. For a production Ed25519 flow, use a native module that supports secure key generation and biometric-gated signing, or implement the key operation in a custom development build. Don't pretend that putting an ordinary private key string into SecureStore automatically makes the key hardware-bound.
The application-level round trip should look like this:
import * as SecureStore from "expo-secure-store";
const DEVICE_KEY_ID = "biometric-device-key";
export async function savePrivateKey(privateKey: string) {
await SecureStore.setItemAsync(DEVICE_KEY_ID, privateKey, {
requireAuthentication: true
});
}
export async function loadPrivateKey() {
return SecureStore.getItemAsync(DEVICE_KEY_ID, {
requireAuthentication: true
});
}
export async function authenticateWithChallenge(
apiBaseUrl: string,
accessToken: string,
sign: (privateKey: string, nonce: string) => Promise<string>
) {
const challengeResponse = await fetch(`${apiBaseUrl}/auth/biometric/challenge`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!challengeResponse.ok) throw new Error("Challenge request failed");
const { nonce } = await challengeResponse.json();
const privateKey = await loadPrivateKey();
if (!privateKey) throw new Error("Biometric key is unavailable");
const signature = await sign(privateKey, nonce);
const verifyResponse = await fetch(`${apiBaseUrl}/auth/biometric/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nonce, signature })
});
if (!verifyResponse.ok) throw new Error("Signature verification failed");
return verifyResponse.json() as Promise<{
accessToken: string;
refreshToken: string;
}>;
}
In a real design, request the challenge before the prompt, sign only after authenticateAsync() succeeds, and reject expired or reused nonces server-side. The server should issue a short-lived access token or rotate a refresh token after verification. It shouldn't treat a locally stored session cookie as proof that the user just authenticated.
For projects using Supabase, the same boundary matters. Biometrics can release a local credential, but your Supabase session still needs controlled refresh, revocation, and logout behavior. The complete Supabase Auth guide for React Native is relevant for that session layer, not as a substitute for device-key verification.
Preload carefully
SecureStore reads can introduce visible latency, especially if a protected read triggers a system prompt. Load non-sensitive metadata at app startup, prepare the key identifier early, and avoid reading protected storage repeatedly during navigation renders. Never preload decrypted secrets into global state unless the threat model explicitly accepts that exposure.
If custom native key management is too large for the first release, use an OS-protected refresh token as an intermediate design. It's still different from a JavaScript boolean gate, but the server won't receive cryptographic proof that a biometric event occurred. Make that trade-off explicit in your risk review.
Face ID Touch ID and Android Differences Compared
The platforms don't provide identical security semantics or prompt behavior. Apple publishes a random false-acceptance probability of about 1 in 1,000,000 for Face ID and about 1 in 50,000 for Touch ID, a 20× difference in selectivity (Apple figures summarized by PanicVault). Those figures shouldn't become a universal risk score, because twins, siblings, younger users, sensor conditions, and the protected action all affect the decision.
Android's BiometricPrompt exposes policy classes rather than a single Face ID or Touch ID equivalent. BIOMETRIC_STRONG, BIOMETRIC_WEAK, and DEVICE_CREDENTIAL can produce different fallback behavior, and Android 10 and later support selecting allowed authenticators through setAllowedAuthenticators. React Native libraries may hide some of that detail, so inspect the native configuration when the fallback policy matters.
| Capability | Face ID | Touch ID | Android BiometricPrompt |
|---|---|---|---|
| Primary signal | Facial recognition through the iOS system prompt | Fingerprint recognition through the iOS system prompt | Fingerprint, face, or another platform-supported biometric |
| Published false-accept reference | About 1 in 1,000,000 | About 1 in 50,000 | Varies by sensor, classifier, and Android biometric class |
| Fallback control | Device passcode policy is controlled by the iOS flow and app configuration | Device passcode policy is controlled by the iOS flow and app configuration | BIOMETRIC_STRONG, BIOMETRIC_WEAK, and DEVICE_CREDENTIAL influence policy |
| Required app configuration | NSFaceIDUsageDescription in the iOS plist | Native biometric capability and appropriate usage configuration | Biometric permissions plus compatible prompt configuration |
| Prompt shape | System modal sheet with native cancellation behavior | System modal sheet with native cancellation behavior | System prompt with platform-specific negative and positive actions |
| Best default use | Fast unlock for a defined risk tier, with step-up for sensitive actions | Convenience unlock where fingerprint enrollment is reliable | Explicitly select the strongest acceptable class and fallback |
The UX also differs in timing. One mobile-device study measured password entry at 0.06 seconds, gesture authentication at 0.13 seconds, face authentication at 1.49 seconds, voice at 2.04 seconds, face plus voice at 4.28 seconds, and gesture plus voice at 3.82 seconds. Taking a photo took 5.55 seconds, and the slowest measured condition exceeded 9.9 seconds (mobile biometrics study).
Stack modalities for step-up verification, not for the primary unlock path. A Face ID prompt followed by voice and another gesture quickly turns “instant login” into abandonment.
Use face or fingerprint as the near-instant local action. Reserve password, passkey, or additional verification for account recovery, risky transfers, changing payout details, and other privileged operations. Android's weaker biometric classes should never inherit the same policy as a stronger class.
Handling Edge Cases Without Breaking Trust
The most damaging biometric bug is simple: treating a cancelled prompt as authentication. A user can press Cancel, receive a result object with success: false, and still reach code that reads a token because the UI only checked whether the prompt promise resolved. Keep the guard boring and absolute:
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Confirm your identity",
fallbackLabel: "Use password"
});
if (!result.success) {
return { status: "cancelled-or-failed" as const };
}
return { status: "authenticated" as const };
In production, use the typed mapper from the earlier section and keep every non-success result out of the authenticated branch. The user's intent matters. Cancellation is neutral, not failure requiring punishment, and never success.
Screenshot from https://example.com/screenshots/biometric-edge-cases.png
Treat device state as mutable
Handle the common branches explicitly:
- Biometrics disabled in Settings:
hasHardwareAsync()may still be true whileisEnrolledAsync()is false. Show password or device-credential login, then offer re-enrollment after the user returns from Settings. - No face or fingerprint enrolled: Don't keep presenting a prompt that can't succeed. Send the user to device settings or continue with the existing account credential.
- A new finger or face is added: Treat the enrollment set as changed. Invalidate the local key or protected token, require the primary account credential, and create a fresh enrollment.
- Operating-system lockout: Map
lockoutor equivalent native errors to a password route. Don't spin up repeated prompts while the OS has blocked biometric attempts. - App reinstall: Assume local storage may be gone. The backend should see the device key as missing or revoked and require a normal login before registering a replacement.
- The app backgrounds during a prompt: Ignore stale completion events if the screen or auth request is no longer active. Re-check navigation state before applying a successful result.
- The user cancels: Leave the session unauthenticated, preserve the screen, and show the alternate method without an alarming error.
Enrollment changes deserve special care because the user may believe they only added a convenient fingerprint. Your security policy may require a fresh account login because the device now recognizes a new biometric. That decision belongs in the credential design, not in a generic success callback.
Keep recovery server-aware
A password fallback shouldn't bypass the same backend controls that protect the biometric route. Rate-limit password attempts, revoke device keys after suspicious recovery, and let the server invalidate sessions when the user reports a lost device. If a protected SecureStore read fails because the key was invalidated, clear the local enrollment marker and require re-enrollment instead of creating an anonymous session.
Test these transitions on physical devices and development builds. Simulators are useful for matching and non-matching scans, but they won't reproduce every vendor-specific lockout, enrollment reset, background interruption, or storage failure.
Where to Go Next With Passkeys and AppLighter
A local Face ID confirmation is a useful stepping stone, not the destination. The stronger model is a device-held private key that signs a server challenge after the user authorizes it. Passkeys apply that challenge-response model through WebAuthn and platform credential providers, so the server verifies a credential rather than trusting a JavaScript result or a static token.
The migration can be incremental:
- Today, gate convenience: Use
expo-local-authenticationto verify that the user is present and route failures to password or device credentials. - Protect the local secret: Store a refresh credential behind Keychain or Keystore controls, and make revocation and recovery server responsibilities.
- Register a device credential: Generate a keypair through a native implementation, send the public key to the backend, and verify signed nonces during login.
- Adopt passkeys: Replace password-centric enrollment with a WebAuthn-compatible credential flow, while keeping account recovery and privileged-action step-up policies explicit.
The market direction supports that progression. By 2024, Mercator's forecast already placed biometric authentication on a majority path among smartphone owners, while Expo's documentation frames local authentication as a device capability and prompt API rather than a complete server identity protocol (Expo LocalAuthentication documentation). Broader passwordless guidance also distinguishes a local biometric event from passkey authentication, where the server validates a cryptographic credential.
AppLighter fits as an opinionated starting point for teams that don't want to assemble navigation, authentication, SecureStore handling, and API glue from disconnected examples. Its React Native templates include authentication and account flows, and the Grocery Delivery template includes a biometric toggle. Treat that as scaffolding, not as a replacement for your threat model, native key strategy, nonce verification, or recovery policy. For broader mobile identity decisions, see this guide to authentication for mobile apps.
Use this adoption checklist before enabling the feature:
- Choose the risk tier: Decide which screens allow local access and which actions require a password, passkey, or server step-up.
- Choose the credential model: Use protected refresh storage for a pragmatic first version, or native key signing when the server needs proof.
- Define enrollment changes: Decide whether adding a biometric invalidates the local credential and forces account reauthentication.
- Define recovery: Cover disabled biometrics, lockout, reinstall, lost devices, cancellation, and revoked sessions.
- Ship behind a feature flag: Collect failure states and device-specific reports before making biometric login the default.
An infographic titled Where to Go Next With Passkeys and AppLighter outlining four steps for using passkeys.
AppLighter provides an Expo React Native starter with authentication, navigation, account flows, and an edge-ready API foundation that can reduce the glue work around a biometric login screen. Use it to scaffold the product flow, then add your chosen SecureStore or native key strategy, server challenge verification, and recovery rules before shipping. Visit AppLighter to evaluate the starter and build the biometric path into a real mobile session rather than a local yes/no gate.