Shipnative
ShipnativeShipnative
Sign in
GuideSeptember 2026 · 9 min read

Expo Local Authentication: Face ID Without the False Sense of Security

expo-local-authenticationis a small module with a large opportunity to mislead you. It asks the OS “is this the enrolled user?” and gives you back a boolean. That is genuinely useful — and it is not authentication, because the boolean is produced by your own JavaScript and never reaches a server. Most Face ID bugs in shipped apps are not sensor problems; they are what happens when a team treats that boolean as proof, forgets that half of users have nothing enrolled, or ships an iOS build that App Review rejects over one missing string. Here is the version that holds up.

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 statehasHardwareAsyncisEnrolledAsyncWhat to do
No sensorfalsefalseNever show the biometric toggle at all
Sensor, nothing enrolledtruefalseShow a "set up Face ID in Settings" hint, keep the toggle off
ReadytruetrueOffer the toggle; label it with the actual sensor type
Enrolment changed since you stored the tokentruetrueTreat 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 loud

Two 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 decides

Note 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

  1. 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.
  2. Cancel the prompt. Swipe it away. Make sure you do not show an error. Then background the app mid-prompt and come back.
  3. Fail it five times. You want to land on lockout deliberately and see where the user ends up.
  4. 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.
  5. 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.

Frequently Asked Questions

What does expo-local-authentication actually do?

It asks the operating system to verify that the person holding the phone is the person enrolled on it, and hands you back true or false. That is the whole API surface. It does not return a token, it does not sign anything, and it does not tell your server anything. It is a local gate in front of something you already have on the device — which is why it has to be paired with a real credential to be worth anything.

Why is a true result from authenticateAsync not enough on its own?

Because it is a boolean produced by your own JavaScript. Anything that can modify the bundle can make that branch always take the success path, and the value never leaves the device, so no server ever checks it. Treat the true as permission to unlock a credential you stored in the keychain, and let the server validate that credential. Biometrics gate access to a secret; they are not the secret.

How do I check if a device supports Face ID or Touch ID?

Two calls, and you need both. hasHardwareAsync() tells you the sensor exists; isEnrolledAsync() tells you the user has actually registered a face or fingerprint. A phone with a sensor and no enrolled biometric returns true then false, and calling authenticate on it fails in a way that looks like a bug. Check hardware, then enrolment, then offer the feature.

What happens if the user has no biometrics enrolled?

Nothing good, unless you planned for it. With disableDeviceFallback left at its default the OS offers the device passcode instead, which is usually the behaviour you want. If you set disableDeviceFallback to true and there is no enrolment, the prompt fails immediately — so that flag needs an in-app fallback of your own behind it, typically your normal email-and-password sign-in.

Does Face ID work in Expo Go?

The module is included in Expo Go, so a prompt appears, but it runs inside the Expo Go app identity rather than yours — the permission string is theirs, the keychain sandbox is shared, and simulator behaviour differs again. Verify the real flow in a development build before you trust what you saw.

Why did App Review reject my app for Face ID?

Almost always a missing or lazy NSFaceIDUsageDescription. iOS requires a purpose string before it will show the Face ID prompt at all, and Apple rejects strings that restate the obvious. Say what the app protects — "Unlocks your saved payment methods" — not "This app uses Face ID". Set it in app.json under ios.infoPlist, or via the config plugin, and rebuild, because it is baked into the native project.

→

Expo SecureStore: Tokens and Limits

Where the credential that biometrics unlocks should actually live.

Read guide →
→

React Native Authentication in 2026

The full sign-in picture — sessions, refresh, and what belongs on the server.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.