
Biometric Authentication in Mobile Apps: Face ID and Fingerprint
Biometrics is the password the user never forgets, never loses, and never reuses across twenty different sites. Face ID and Touch ID eliminated the biggest friction point in mobile login: typing a complex password on a 6-inch screen. The practical result is straightforward -- apps with biometric authentication have significantly higher open rates and less abandonment on the login screen.
But implementing biometrics correctly goes beyond calling an API. It involves secure token storage, robust fallback for when biometrics are unavailable, and a UX flow that doesn't frustrate the user in edge cases.
expo-local-authentication: Setup and Compatibility
The expo-local-authentication library abstracts Face ID, Touch ID (iOS), and Android Biometrics into a single API. Before authenticating, always check device support:
import * as LocalAuthentication from 'expo-local-authentication';
async function checkBiometricSupport() {
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
const supportedTypes = await LocalAuthentication.supportedAuthenticationTypesAsync();
return {
hasHardware, // does the device have a sensor?
isEnrolled, // has the user enrolled biometrics?
hasFaceId: supportedTypes.includes(
LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION
),
hasFingerprint: supportedTypes.includes(
LocalAuthentication.AuthenticationType.FINGERPRINT
),
};
}
async function authenticateWithBiometrics(): Promise<boolean> {
const support = await checkBiometricSupport();
if (!support.hasHardware || !support.isEnrolled) {
return false; // fall back to PIN
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Confirm your identity to continue',
fallbackLabel: 'Use PIN', // fallback button text (iOS)
cancelLabel: 'Cancel',
disableDeviceFallback: false, // allow native system fallback
});
return result.success;
}
On iOS, you need to add the permission in Info.plist via app.json:
{
"expo": {
"ios": {
"infoPlist": {
"NSFaceIDUsageDescription": "We use Face ID to protect your account and speed up login."
}
}
}
}
Without this description, the app is rejected during App Store Review. The text needs to clearly explain why the app needs Face ID -- being too generic also causes rejection.
A note on compatibility: disableDeviceFallback: false allows the operating system to offer its native fallback (device PIN/passcode) when biometrics fail repeatedly. This is different from your app's fallback -- the system one kicks in after several consecutive failed attempts.
Secure Token Storage with expo-secure-store
Storing authentication tokens in AsyncStorage is a security mistake. AsyncStorage is not encrypted and can be accessed on jailbroken or rooted devices. expo-secure-store uses iOS Keychain and the Android Keystore System -- both hardware-protected and, in many cases, tied to biometrics.
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'auth_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
export async function saveTokens(accessToken: string, refreshToken: string) {
await SecureStore.setItemAsync(TOKEN_KEY, accessToken);
await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken);
}
export async function getAccessToken(): Promise<string | null> {
return SecureStore.getItemAsync(TOKEN_KEY);
}
export async function clearTokens() {
await SecureStore.deleteItemAsync(TOKEN_KEY);
await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
}
The complete biometric login flow works like this:
- User logs in with email/password (first time)
- Backend returns
access_tokenandrefresh_token - App saves the tokens in SecureStore
- On subsequent app opens, the app presents biometric authentication
- If approved, retrieves the token from SecureStore and authenticates the session
- If the token is expired, uses the
refresh_tokento silently renew it
This model means the actual credentials (email/password) are entered only once -- everything after that is managed by tokens.
PIN Fallback: When Biometrics Are Unavailable
Biometrics can be unavailable for several reasons: device without a sensor, user hasn't enrolled biometrics, too many failed attempts, or the user simply chose not to use it. Your app needs a functional alternative path for all these cases.
Good fallback UX for an in-app PIN (different from the system PIN) involves a PIN creation flow during onboarding and a PIN entry screen as an alternative to biometrics:
import { useState } from 'react';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
const PIN_HASH_KEY = 'user_pin_hash';
async function createPin(pin: string) {
const hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
pin + 'your-salt-here' // use a unique per-user salt in production
);
await SecureStore.setItemAsync(PIN_HASH_KEY, hash);
}
async function verifyPin(pin: string): Promise<boolean> {
const storedHash = await SecureStore.getItemAsync(PIN_HASH_KEY);
if (!storedHash) return false;
const inputHash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
pin + 'your-salt-here'
);
return storedHash === inputHash;
}
Never store the PIN in plaintext, even in SecureStore. The hash with salt ensures that even if SecureStore is compromised, the original PIN is not recoverable.
| Scenario | Expected behavior |
|---|---|
| Biometrics available and enrolled | Present biometrics automatically on app open |
| Biometrics available, not enrolled | Offer enrollment or fallback to PIN |
| Biometrics unavailable (hardware) | Go straight to PIN |
| 5 failed biometric attempts | Lock biometrics, require PIN |
| User cancelled biometrics | Offer PIN option without forcing |
Login UX: A Flow That Doesn't Frustrate
The biggest source of frustration in biometric authentication is the app requesting biometrics at the wrong time or not providing a clear exit when it fails. Some practical guidelines:
Trigger biometric authentication automatically, but with a delay.
Calling authentication immediately in the mount useEffect, without waiting for the screen to render, creates a confusing experience. Add a 300-500ms delay so the user sees the screen before the dialog appears.
Show which type of biometrics is available.
"Touch the sensor" for Touch ID and "Look at the camera" for Face ID are different instructions. Use the result of supportedAuthenticationTypesAsync() to customize the text and icon.
Always keep the fallback button visible. Never hide the PIN option. Users with dirty hands, sunglasses, or in difficult lighting need a quick exit.
Don't repeat the prompt in a loop. If authentication fails and the user cancels, respect that decision. Don't show the dialog again automatically. Offer a "Try again" button instead of triggering the prompt without user action.
Loading state after authentication. Between biometric confirmation and the main screen, there's a network call to validate the token. Show a loading indicator during this gap -- it prevents the feeling that the app is frozen.
Conclusion
Well-implemented biometric authentication is transparent to the user -- it simply works, fast, without friction. But "simply working" requires attention to detail: securely stored tokens, clear fallbacks, and a UX that anticipates the cases where biometrics are not available.
At SystemForge, mobile security is not a layer added after the fact -- it is designed alongside the app's architecture from the start. If you're building an app that handles sensitive data or financial transactions and want to ensure authentication is implemented the right way, reach out to our team.
Need help?

