Biometric Auth in React Native 2026: Face ID, Passkeys, and the Keychain Traps
Face ID and fingerprint look simple from the product side. The keychain semantics, passkey fallbacks, and review-time edge cases are where React Native teams lose a week. Here's what we've learned shipping it.

Biometric login looks like a two-line feature in the spec doc: "Let users sign in with Face ID or fingerprint." Then you ship it, and three weeks later you're debugging why a subset of users get logged out every OS update, why Android shows the prompt twice, and why App Review rejected the build for "misleading use of Face ID."
This is a field guide to doing biometric auth properly in a React Native or Expo app in 2026, including the shift toward passkeys that Apple and Google are quietly pushing everyone into.
What biometrics actually do (and don't do)
First, the mental model. expo-local-authentication and react-native-biometrics do not authenticate the user against your server. They authenticate the user against the device. The OS confirms "yes, the person holding this phone is the enrolled owner," and hands you back a boolean or a signed payload.
That distinction matters because it shapes everything downstream:
- A successful Face ID prompt is not a session. You still need a token, key, or signed challenge tied to your backend.
- If the user adds a new fingerprint or re-enrols Face ID, the OS can (and should) invalidate any biometric-bound keys you stored. That's a feature, not a bug.
- The biometric prompt itself is not a secret store. The Keychain (iOS) and Keystore (Android) are.
If you treat the biometric check as "unlock my SecureStore item," you're on the right track. If you treat it as "the user is authenticated, let them in," you're building a bug.
The three patterns we actually see in production
There are really only three architectures worth considering. Pick one deliberately.
Pattern A: biometric-gated token cache
The user logs in once with email/password or OAuth. You store a refresh token in the Keychain/Keystore behind a biometric ACL. On next launch, Face ID unlocks the refresh token, you swap it for an access token, done.
This is the right default for most apps. It's simple, it survives offline cold starts (because you only need the network when refreshing the access token), and it degrades cleanly to passcode fallback.
Pattern B: biometric-signed server challenge
You generate a key pair on enrolment, store the private key behind biometrics, and register the public key with your backend. On each sensitive action, the server issues a nonce, the device signs it after a biometric prompt, and the server verifies.
Use this for banking, healthcare adjacent flows, or anything where "unlock a cached token" isn't strong enough. It's more code, but you get true possession-and-inherence on every call.
Pattern C: passkeys (WebAuthn)
This is where things are heading. iOS 17+ and Android 14+ both expose platform passkey APIs, and in 2026 a meaningful share of users already have a passkey they synced from another device. With react-native-passkey or the platform-specific Credential Manager APIs, you can skip building your own key-pair flow.
We'd default to passkeys for any new product launching in 2026 where the backend team is willing to implement WebAuthn registration and assertion endpoints. The UX is better and you stop owning password storage entirely.
Implementing Pattern A cleanly in Expo
Here's the minimum viable shape we use for the token-cache pattern, assuming Expo SDK 52+ and expo-local-authentication plus expo-secure-store:
import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';
const REFRESH_KEY = 'auth.refreshToken';
export async function storeRefreshToken(token: string) {
await SecureStore.setItemAsync(REFRESH_KEY, token, {
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
requireAuthentication: true,
authenticationPrompt: 'Confirm it\'s you to stay signed in',
});
}
export async function getRefreshToken(): Promise<string | null> {
const capability = await LocalAuthentication.hasHardwareAsync();
const enrolled = await LocalAuthentication.isEnrolledAsync();
if (!capability || !enrolled) {
// Fall back to a password re-auth flow, not a silent failure.
return null;
}
try {
return await SecureStore.getItemAsync(REFRESH_KEY, {
requireAuthentication: true,
authenticationPrompt: 'Sign in with Face ID',
});
} catch (err) {
// User cancelled, or key invalidated by biometric re-enrolment.
return null;
}
}
A few things worth calling out:
WHEN_UNLOCKED_THIS_DEVICE_ONLYis what you almost always want. It prevents iCloud Keychain from syncing the token to other devices, which would defeat the device-bound guarantee.requireAuthentication: trueis what actually binds the item to biometrics. Without it, you've just stored plaintext in the Keychain with no prompt.- Wrap reads in a
try/catch. The OS will throw, not return null, when biometrics change. Catch it, clear the token, and route the user to a fresh login. Do not loop the prompt.
Android-specific traps
The iOS story is relatively clean. Android is where most of the wasted hours go.
StrongBox vs TEE. On Pixels and most flagships, you get StrongBox-backed keys. On older or budget Android devices, you fall back to the TEE, and on a small slice of devices the hardware-backed Keystore is unreliable enough that setUserAuthenticationRequired(true) keys occasionally vanish after OS updates. Build your refresh flow assuming the key can disappear at any time.
BiometricPrompt vs FingerprintManager. If you're using a library that still routes through FingerprintManager under the hood on Android 13+, replace it. The modern BiometricPrompt API handles class 3 biometrics, device credential fallback, and the unified UI. expo-local-authentication does the right thing here; some older community modules don't.
The double-prompt bug. If you call authenticate() and then immediately try to read a biometric-bound Keystore key, some Android OEMs prompt twice. The fix is to use the CryptoObject overload so the prompt and the key unlock happen in a single OS-managed transaction.
Detecting biometric class
For sensitive flows, check the biometric class before trusting it:
const level = await LocalAuthentication.getEnrolledLevelAsync();
// 0 = none, 1 = passcode/pattern, 2 = weak biometric, 3 = strong biometric
if (level < 2) {
// Force password re-auth for high-value actions.
}
Face unlock on cheap Android phones often registers as weak. Treating it the same as a Pixel's class 3 face unlock is how you end up in an incident review.
App Review pitfalls
We've had builds rejected for three specific biometric mistakes, all avoidable.
NSFaceIDUsageDescriptionmissing or generic. Apple wants a sentence that names the feature, e.g. "Face ID is used to unlock your saved sign-in so you don't have to type your password." "Authentication" alone gets flagged.- Biometric gate with no opt-out. If the only way into the app is Face ID, and the user declines it during onboarding, you must offer a password or passcode path. "Try again" loops get rejected as a usability failure.
- Misusing Face ID as a paywall gate. We've seen reviewers reject apps that prompt Face ID to confirm a purchase that's actually being processed through StoreKit. StoreKit already does its own auth; adding a Face ID prompt on top reads as misleading. Use biometrics for your auth, not for confirming Apple's.
On Android, Play Review is more permissive but the Data Safety form now asks explicitly whether biometric data leaves the device. The answer is no — the OS never gives you the raw biometric — and you should say so.
Where passkeys change the calculus
If you're greenfielding an app in 2026, seriously consider skipping password-plus-biometric-cache entirely. The passkey flow looks like this:
- On registration, the device generates a key pair, stores the private half in the platform's synced credential store, and sends the public key to your backend.
- On sign-in, the backend issues a challenge, the OS prompts the user with Face ID or fingerprint, signs the challenge, and returns the assertion.
- The user can sign in on a new device by approving from an existing one — no "forgot password" flow, no SMS OTP.
The React Native story here improved meaningfully in late 2025. react-native-passkey exposes the platform APIs on both sides, and the Expo config plugin handles the associated domains and asset links files that used to be the worst part of setup. You still need a WebAuthn-capable backend, but if you're using Auth0, Clerk, Stytch, or Supabase, they've all shipped passkey support.
The one caveat: passkey UX on Android is still inconsistent across OEM skins. We test on at least one Samsung, one Pixel, and one Xiaomi device before signing off.
Where we'd start
If you've got an existing app with email/password and you want biometrics by next sprint: ship Pattern A with expo-local-authentication and expo-secure-store, gate it behind a clear opt-in screen, and write integration tests that simulate biometric invalidation. That alone gets you 80% of the value.
If you're starting fresh, or you're planning a security review in the next six months, build on passkeys from day one and treat email/password as the fallback, not the default. The migration from password-first to passkey-first is painful enough that you don't want to do it twice.
Either way, assume the OS will invalidate your keys at the worst possible moment, and design the recovery path before you design the happy path. That's the bit teams skip, and it's the bit users actually feel.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Push Notifications in Expo and React Native: Why Yours Are Silently Failing in 2026
Push delivery looks fine in your dashboard but users swear they never got the notification. Here's what actually breaks push on iOS and Android in 2026, and how to debug it before support tickets pile up.

EAS Update OTA Strategy in 2026: Channels, Rollouts, and Rollbacks Without Breaking Review
OTA updates are the best superpower Expo gives you — and the easiest way to get your app pulled. Here's how we structure EAS Update channels, staged rollouts, and rollbacks without tripping Apple's review rules.

In-App Purchases in React Native: What Apple and Google Actually Reject in 2026
A practical breakdown of the IAP rejection patterns we keep seeing in React Native and Expo apps in 2026 — from receipt validation to subscription restore flows — with code you can lift straight into your project.
