What the boolean is and is not
The mental model that keeps you out of trouble: biometrics are a lock on a drawer, not the thing inside the drawer. The OS confirms the person is enrolled. Your code then decides what that permits. If what it permits is “set isLoggedIn = true,” you have built a decoration. If it permits “read the refresh token out of the keychain and exchange it with the server,” you have built something real, because the server is still the authority and a tampered client has nothing to present.
This matters for a specific practical reason. Anyone with the app bundle can read your JavaScript. A branch that flips a local flag on true is a branch that can be forced. A branch that decrypts a credential is not, because the credential is what the server checks and the OS is what releases it. Same three lines of code, completely different security property, and the difference is entirely in what sits behind the if.
Install, and the string that gets you rejected
npx expo install expo-local-authentication # native code — rebuild, don't just reload: npx expo run:ios # or: eas build --profile development --platform ios
// app.json
{
"expo": {
"ios": {
"infoPlist": {
"NSFaceIDUsageDescription":
"Unlocks your saved payment methods without typing your password."
}
}
}
}iOS will not show the Face ID prompt at all without NSFaceIDUsageDescription, and App Review rejects strings that restate the mechanism instead of the purpose. “This app uses Face ID” is a rejection. “Unlocks your saved payment methods”is not. Say what is being protected, in the user’s words.
Android needs no equivalent string — the permission is declared by the library. Which means an Android-only test run will never surface this, and the first time you find out is a TestFlight build where the prompt silently never appears. Test the iOS prompt on a device before submitting. See the config plugins guide if you are managing native config across environments.
The four device states you have to handle
Almost every “Face ID is broken” report is one of these four states handled as if it were a different one:
| Device state | hasHardwareAsync | isEnrolledAsync | What to do |
|---|---|---|---|
| No sensor | false | false | Never show the biometric toggle at all |
| Sensor, nothing enrolled | true | false | Show a "set up Face ID in Settings" hint, keep the toggle off |
| Ready | true | true | Offer the toggle; label it with the actual sensor type |
| Enrolment changed since you stored the token | true | true | Treat the stored credential as stale and re-authenticate |
import * as LocalAuthentication from 'expo-local-authentication';
export async function biometricStatus() {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
if (!hasHardware) return { available: false, reason: 'no-sensor' } as const;
const enrolled = await LocalAuthentication.isEnrolledAsync();
if (!enrolled) return { available: false, reason: 'not-enrolled' } as const;
const types = await LocalAuthentication.supportedAuthenticationTypesAsync();
return { available: true, types } as const;
}supportedAuthenticationTypesAsyncis what lets you write “Unlock with Face ID” on a Face ID phone and “Unlock with fingerprint” on an Android one. A generic “Unlock with biometrics” label reads like a settings menu written by a developer, because it is. Note it returns the sensor types the hardware supports — pair it with the enrolment check rather than using it as one.
The prompt, and the fallback ladder
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Unlock your vault',
cancelLabel: 'Use password',
disableDeviceFallback: false, // let the OS offer the passcode
});
if (result.success) {
// unlock the real credential, don't just set a flag
return unlockSessionFromKeychain();
}
// result.error is a string code: 'user_cancel', 'lockout',
// 'not_enrolled', 'user_fallback', 'system_cancel', ...
if (result.error === 'user_fallback' || result.error === 'lockout') {
return goToPasswordSignIn();
}
// 'user_cancel' and 'system_cancel' are not failures — do nothing loudTwo decisions live in that block. First, disableDeviceFallback: left false, the OS offers the device passcode after a failure, which is almost always right — the passcode is a stronger factor than the face anyway. Set it true only when you have your own fallback screen ready, because otherwise a user with a scarred thumb has no route into your app.
Second, the error codes are not all errors. user_cancelmeans the user changed their mind — showing a red “Authentication failed” toast there is the most common polish bug in this whole feature. system_cancel fires when a call comes in or the app backgrounds mid-prompt, which happens constantly in real use. Only lockout and user_fallbackgenuinely mean “send them somewhere else.”
Wiring it to a credential that actually matters
The full pattern, and the reason to bother with any of this. The token lives in the keychain; biometrics release it; the server validates it:
import * as SecureStore from 'expo-secure-store';
// at sign-in: store the refresh token behind the OS gate
await SecureStore.setItemAsync('refresh_token', token, {
requireAuthentication: true, // OS asks for Face ID on read
keychainAccessible: SecureStore.WHEN_UNLOCKED,
});
// at app open: the read itself triggers the prompt
const refresh = await SecureStore.getItemAsync('refresh_token', {
requireAuthentication: true,
});
if (!refresh) return goToPasswordSignIn();
const session = await api.exchangeRefreshToken(refresh); // server decidesNote what changed: with requireAuthentication: true on the SecureStore item, the keychain itself demands the biometric. You are no longer branching on a boolean at all — the OS refuses to hand over the bytes. That is the version worth shipping, and it is why where you store the token matters more than how you prompt.
One caveat to plan for: on iOS, changing the enrolled biometric can invalidate items stored this way, and the read then fails rather than prompting. Handle that failure as “credential is gone, sign in normally,” not as an error state — a user who added a second face should get a password screen, not a crash report.
Five things worth checking before you ship
- A device with no enrolment. Wipe Face ID in Settings and open your app. If the toggle is still there, or the prompt fails silently, fix that first — it is the single most common broken path.
- Cancel the prompt. Swipe it away. Make sure you do not show an error. Then background the app mid-prompt and come back.
- Fail it five times. You want to land on
lockoutdeliberately and see where the user ends up. - Reinstall the app. On iOS keychain items survive deletion, so a reinstall can silently unlock a stale session — the uninstall trap covered in the SecureStore guide.
- Turn biometrics off in your own settings screen. If you offer the toggle, disabling it must actually delete the stored credential, not just flip a preference.
Every one of these is a two-minute manual test and each maps to a real support ticket. There is no automated substitute — the OS prompt is outside your app, so a simulator run proves very little.
Getting the scaffolding for free
Biometric unlock is a small feature sitting on top of a large one: you need a real sign-in, a session that refreshes, and a secure place to keep the token before the Face ID prompt means anything. If you are starting from scratch, describe your app at shipnative.dev — it generates a React Native project with the auth flow and secure storage already wired, so the biometric layer is the fifteen lines above rather than a week of plumbing. You get the whole Expo project to edit, so none of this is a black box.