Biometric Auth in React Native: Face ID, Android Keystore, and the Silent Lockouts Nobody Warns You About
Face ID and fingerprint auth look like a two-line integration until users start getting locked out after an OS update. Here's how we ship biometric login in React Native without the 3am support tickets.

Biometric login is one of those features that demos in five minutes and haunts you for eighteen months. The happy path — prompt, thumb, in — is trivial. The unhappy paths are where support tickets are born: users who changed their Face ID enrollment, Android 14 devices that quietly invalidated your key, and iPad users whose Touch ID sensor was disabled by MDM.
This is what we've learned shipping biometric auth in React Native and Expo apps across finance, health, and B2B verticals — and where Swift/Kotlin would have made your life easier (or not).
What biometric auth actually is (and isn't)
Before picking a library, get the mental model right. There are two very different things people call "biometric login":
- Biometric gate — the app already has a valid session token. Face ID is just a UI gate that says "yes, the phone owner is present" before revealing the screen. This is what
expo-local-authenticationdoes out of the box. - Biometric-bound credential — a secret (refresh token, encryption key, or private key) is stored in the Secure Enclave / Android Keystore and can only be released after a successful biometric check. If someone jailbreaks the phone or swaps the biometric enrollment, the key becomes unusable.
Most React Native tutorials show you option 1 and call it done. Option 1 is fine for a note-taking app. If you're building anything touching money, health data, or enterprise access, you want option 2.
Why the distinction matters for review
Apple's reviewers have started pushing back on apps that gate sensitive data purely with a JavaScript-side if (success) check. If your bundle is decompiled and the biometric result is just a boolean crossing the bridge, an attacker with device access can patch it. Binding the actual secret to the keystore removes that class of attack — and it's the difference between "we use Face ID" and "we use Face ID meaningfully".
The library landscape in 2026
Three options dominate:
expo-local-authentication— the default for Expo apps. Great API, but it's a gate, not a credential store.react-native-keychain— mature, works in bare and prebuild workflows, supportsACCESS_CONTROL.BIOMETRY_CURRENT_SETon iOS andsetUserAuthenticationRequiredon Android.expo-secure-storewith therequireAuthenticationoption — the Expo-native way to do credential binding. Improved a lot since SDK 51, and in our experience it now covers 90% of what teams reach forreact-native-keychainfor.
For most Expo apps we start with expo-secure-store + expo-local-authentication and only drop to react-native-keychain if we need finer keystore control (e.g., StrongBox on Android, or specific LAPolicy variants on iOS).
A realistic implementation
Here's the shape we use for a login flow that stores a refresh token bound to biometrics. This assumes Expo SDK 52+ but the pattern is the same in bare RN.
import * as SecureStore from 'expo-secure-store';
import * as LocalAuthentication from 'expo-local-authentication';
const REFRESH_KEY = 'auth.refresh_token';
export async function enrollBiometricLogin(refreshToken: string) {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (!hasHardware || !isEnrolled) {
throw new Error('BIOMETRICS_UNAVAILABLE');
}
await SecureStore.setItemAsync(REFRESH_KEY, refreshToken, {
requireAuthentication: true,
authenticationPrompt: 'Confirm it\'s you to enable quick sign-in',
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
}
export async function biometricSignIn(): Promise<string | null> {
try {
const token = await SecureStore.getItemAsync(REFRESH_KEY, {
requireAuthentication: true,
authenticationPrompt: 'Sign in',
});
return token;
} catch (err: any) {
// Key invalidated (new fingerprint enrolled, passcode removed, etc.)
if (err?.code === 'ERR_SECURE_STORE_AUTHENTICATION_FAILED') {
await SecureStore.deleteItemAsync(REFRESH_KEY);
return null;
}
throw err;
}
}
Three things to notice:
WHEN_UNLOCKED_THIS_DEVICE_ONLYprevents the token from restoring to a new device via iCloud backup. If you skip this, users restoring a phone will think they're still logged in — until the server rejects them.- We catch
ERR_SECURE_STORE_AUTHENTICATION_FAILEDand wipe the key. This is the single most important line in the file. More on that next. - We never store the password or a long-lived access token here. Only a refresh token that the server can revoke.
The silent lockout problem
Here's the war story. A fintech client shipped biometric login. Adoption was strong. Six weeks in, support started seeing a trickle of "the app makes me log in every day" complaints. Not a flood — maybe 0.5% of users per week. But they were high-value users, and they were furious.
The cause: on both iOS and Android, adding or removing a biometric enrollment invalidates biometric-bound keychain items. On iOS this is kSecAccessControlBiometryCurrentSet. On Android it's setInvalidatedByBiometricEnrollment(true), which defaults to true. This is a feature — it prevents someone who steals your phone and adds their fingerprint from unlocking your bank app. But nobody tells the user why they got kicked out.
Worse: iOS 17+ also invalidates the key when the device passcode is removed and re-added, and some Android OEMs invalidate on major OS upgrades. Samsung's One UI has done this at least twice in our experience.
What to actually do about it
- Detect the invalidation gracefully. Catch the auth error, delete the stored credential, and route the user to a normal password/magic-link login. Don't show a scary red error.
- Explain it once. After the fallback login succeeds, show a one-time modal: "Your device security settings changed, so we asked you to sign in again. Re-enable Face ID?" Users accept this. They do not accept mystery logouts.
- Track it as a metric, not a crash. We log
biometric_key_invalidatedas a product event, split by OS version. It's how you catch the next Samsung update before your CS team does.
iOS-specific traps
- Face ID requires
NSFaceIDUsageDescriptionin Info.plist. Missing this string doesn't just show a generic prompt — it crashes the app on first Face ID use. Expo config plugins usually handle it, but check your prebuild output. - iPad Touch ID is sometimes disabled by MDM in enterprise deployments.
isEnrolledAsync()returnsfalseeven though the hardware exists. Your UI copy should say "biometrics unavailable," not "Face ID unavailable." - Simulator lies. The iOS Simulator's "Enrolled" toggle behaves nothing like a real device — key invalidation semantics are especially different. Test on hardware or you will ship bugs.
Android-specific traps
- BiometricPrompt vs. FingerprintManager. Anything using the deprecated
FingerprintManagerwill misbehave on Android 14+. Bothexpo-local-authenticationand modernreact-native-keychainuseBiometricPrompt. If you have an older custom module, audit it. - Class 3 (Strong) vs. Class 2 (Weak). Only Class 3 biometrics can unlock keystore-bound keys. Some cheap Android devices only ship Class 2 face unlock. On those devices, your biometric-bound flow will fail and you need a PIN fallback.
- Work profile devices may block biometric access to keystore entirely. Handle the exception, don't assume it means the user cancelled.
When to just write it native
We'll say the quiet part: if biometric auth is a core primitive of your product — say you're building a password manager or a hardware wallet companion — write the sensitive path in Swift and Kotlin and expose it via a Turbo Module. The React Native ecosystem is solid for 95% of apps, but when you need LAContext reuse, SecAccessControlCreateFlags combinations, or Android's BiometricPrompt.CryptoObject bound to a specific Cipher, you'll fight your library more than you fight the platform.
For everything else — banking dashboards, healthcare portals, B2B apps, e-commerce with stored payment methods — expo-secure-store with requireAuthentication is genuinely fine, and it'll save you weeks.
Where we'd start
If you're adding biometric login to an existing React Native app this quarter:
- Decide today whether you're building a gate or a credential store. Write it down. It changes everything downstream.
- Ship the invalidation-recovery path before the happy path. Seriously. Write the fallback first, then wire the enrollment.
- Instrument
biometric_key_invalidated,biometric_hardware_unavailable, andbiometric_user_cancelledas separate events. You'll want them the first time a support ticket says "the app is broken." - Test on at least one budget Android device (Class 2 biometrics) and one iPad. Simulators won't catch these.
If you want a second pair of eyes on an auth flow before it ships, that's the kind of thing our mobile team does most weeks — and it's a lot cheaper than the post-launch rewrite.
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 React Native 2026: Expo Push, FCM HTTP v1, and APNs Tokens That Silently Expire
Push looks simple until 30% of your Android sends fail overnight and iOS silent notifications stop waking your app. Here's how the stack actually breaks in 2026 and how we wire it.

EAS Update in 2026: When OTA Saves You and When It Gets You Rejected
OTA updates are the best part of Expo — until they aren't. A field guide to what you can safely ship over-the-air in 2026, what will get your app pulled, and how we structure release channels to sleep at night.
In-App Purchases in React Native: RevenueCat, StoreKit 2, and the Receipts That Lie
A practical breakdown of wiring IAP and subscriptions into a React Native app in 2026 — what RevenueCat actually saves you, where StoreKit 2 still bites, and the receipt edge cases that cost real revenue.
