
How to Add Face ID/Biometric Login to Your Expo+Clerk App
Welcome to Part 1 of our guide on adding Face ID and biometric login to your Expo and Clerk application. In this part, we will set up the Expo project, configure Clerk authentication with standard email and password, check for biometric availability on the device, and securely store the user's credentials after their first password-based sign-in.
Mobile users expect fast, frictionless authentication. Typing a password on a phone keyboard is slow, error-prone, and increasingly unnecessary. Biometric authentication — Face ID on iPhone, Touch ID on older Apple devices, and fingerprint or face unlock on Android — lets users sign in with a glance or a touch. Cisco's 2022 Trusted Access Report, based on 13 billion authentications from nearly 50 million devices, found that 81% of smartphones have biometrics enabled, and the MojoAuth Passwordless Conversion Impact Report found that biometric authentication completes in an average of 0.7 seconds.
Authentication friction has a direct cost. Descope reports that 48% of users have abandoned a purchase due to a forgotten password, and Baymard Institute's checkout research puts the average online cart abandonment rate at 70.19%. When MojoAuth analyzed 523.7 million authentication events, apps that added passwordless and biometric sign-in saw login success rates jump from 67.4% to 97.2%.
This tutorial walks through adding biometric login to an Expo app using Clerk's useLocalCredentials() hook. By the end, you will have a working Expo development build where users sign in with email and password once, then use Face ID (iOS), Touch ID (iOS), or fingerprint (Android) for all subsequent logins.
Understanding biometric authentication on mobile
Before writing code, it helps to understand the two distinct approaches to biometric authentication in mobile apps. They solve different problems and suit different scenarios.
Local credential storage with biometric gating
This approach stores a user's password credentials in the device's secure enclave — the iOS Keychain or Android Keystore — and uses a biometric prompt to unlock them. The user signs in with a password once. On subsequent visits, the app presents a Face ID or fingerprint prompt. If the biometric check passes, the stored credentials are retrieved and sent to the server to complete a standard password-based sign-in.
Think of it like a browser's password manager, but unlocked with your face or fingerprint instead of a master password. The password still exists — biometrics are a convenience layer that removes the need to type it repeatedly.
Clerk implements this pattern through the useLocalCredentials() hook, which handles credential storage, biometric verification, and sign-in in a single API.
Passkeys (FIDO2/WebAuthn)
Passkeys take a fundamentally different approach. Instead of storing a password, the device generates an asymmetric key pair. The private key stays in the secure enclave and never leaves the device. The public key is sent to the server. During authentication, the server sends a challenge, the device uses a biometric prompt to unlock the private key and sign the challenge, and the server verifies the signature against the stored public key.
No password is ever created, stored, or transmitted. Passkeys sync across devices via iCloud Keychain (Apple) or Google Password Manager (Android), and they are phishing-resistant because they are cryptographically bound to a specific domain. The FIDO Alliance's 2025 Passkey Index found that passkey-based logins succeed 93% of the time compared to 63% for traditional passwords.
Clerk supports passkeys in Expo through the @clerk/expo-passkeys package, which provides user.createPasskey() for registration and signIn.passkey() for authentication.
When to use each approach
Local credentials are the right choice when your app already uses password-based sign-in and you want to add biometric convenience with minimal setup. This is the approach this tutorial implements. Passkeys are ideal for new apps going passwordless from the start. Clerk's passkey package currently requires Expo SDK 53 or 54 — Part 2 of this series covers passkeys as a conceptual overview.
Prerequisites
Development environment
- Node.js 22 LTS — download from nodejs.org
- Expo CLI — included with
npx expo(no global install needed) - Xcode — required for iOS development builds (macOS only)
- Android Studio — required for Android development builds
- iOS Simulator with Face ID support, or a physical iOS device
- Android emulator or physical device
Accounts and services
- Clerk account — the free tier works for this tutorial. Sign up at clerk.com.
- Apple Developer account — only needed if testing on a physical iOS device. Simulator testing does not require one.
Why you need a development build (not Expo Go)
Expo Go is a pre-built app with a fixed set of native libraries. It cannot include custom native code like the NSFaceIDUsageDescription entry in the iOS Info.plist or the USE_BIOMETRIC permission in Android's AndroidManifest.xml. A development build is your own custom version of Expo Go that includes these native modules. JavaScript hot-reload still works the same way — you only need to rebuild when adding or removing native dependencies.
Setting up the Expo project
Create a new Expo project
npx create-expo-app biometric-clerk-app --template blank-typescript
cd biometric-clerk-appThis creates a new TypeScript Expo project with the standard file structure.
Install dependencies
npx expo install expo-local-authentication expo-secure-store @clerk/expoEach package serves a specific purpose:
expo-local-authentication— provides the biometric prompt API (hasHardwareAsync,isEnrolledAsync,authenticateAsync)expo-secure-store— encrypted credential storage using the iOS Keychain and Android EncryptedSharedPreferences@clerk/expo— Clerk's Expo SDK with authentication hooks, includinguseLocalCredentials
Configure biometric permissions
Open app.json and add the plugin configuration:
{
"expo": {
"name": "biometric-clerk-app",
"slug": "biometric-clerk-app",
"scheme": "biometric-clerk-app",
"plugins": [
[
"expo-local-authentication",
{
"faceIDPermission": "Allow $(PRODUCT_NAME) to use Face ID for quick sign-in to your account."
}
],
"expo-secure-store"
]
}
}The faceIDPermission string sets the NSFaceIDUsageDescription value in the iOS Info.plist. iOS requires this string or the app will crash when requesting Face ID access. Apple also rejects App Store submissions that are missing this key. On Android, the expo-local-authentication plugin automatically adds the USE_BIOMETRIC and USE_FINGERPRINT permissions to the AndroidManifest.xml.
Create a development build
Build and run the app on each platform:
npx expo run:iosThis command generates the native iOS project (if it does not exist), compiles it, and launches the app in the iOS Simulator. The first build takes a few minutes. Subsequent rebuilds are faster because only changed native code recompiles.
For Android, run the equivalent command:
npx expo run:androidTesting Face ID in the iOS Simulator: Open the Simulator, go to Features → Face ID → Enrolled to enable Face ID. During testing, use Features → Face ID → Matching Face to simulate a successful scan or Non-matching Face to simulate a failure.
Setting up Clerk authentication
Create a Clerk application
- Sign in to the Clerk Dashboard.
- Create a new application (or select an existing one).
- Under User & authentication, enable Password as a sign-in option.
- Copy your Publishable Key from the application's API keys page.
Configure environment variables
Create a .env file in the project root:
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your-key-hereExpo requires the EXPO_PUBLIC_ prefix for client-side environment variables. Replace pk_test_your-key-here with your actual Publishable Key from the Clerk Dashboard.
Configure the Clerk provider
Create the root layout at app/_layout.tsx:
import { Slot } from 'expo-router'
import { ClerkProvider } from '@clerk/expo'
import { tokenCache } from '@clerk/expo/token-cache'
export default function RootLayout() {
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!
if (!publishableKey) {
throw new Error('Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to your .env file')
}
return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<Slot />
</ClerkProvider>
)
}The ClerkProvider wraps the entire app and provides authentication state to all child components. The tokenCache prop tells Clerk to use expo-secure-store for persisting session tokens securely.
Build sign-in and authenticated screens
This tutorial uses Expo Router's route group pattern with useAuth() and <Redirect> for authentication guards. Here is the file structure:
app/
├── _layout.tsx ← Root: ClerkProvider + Slot
├── (auth)/
│ ├── _layout.tsx ← Guard: redirects signed-in users to "/"
│ └── sign-in.tsx ← Sign-in screen
└── (home)/
├── _layout.tsx ← Guard: redirects signed-out users to sign-in
└── index.tsx ← Authenticated home screenCreate the auth route guard at app/(auth)/_layout.tsx:
import { Redirect, Stack } from 'expo-router'
import { useAuth } from '@clerk/expo'
export default function AuthLayout() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) return null
if (isSignedIn) {
return <Redirect href="/" />
}
return <Stack />
}Create the home route guard at app/(home)/_layout.tsx:
import { Redirect, Stack } from 'expo-router'
import { useAuth } from '@clerk/expo'
export default function HomeLayout() {
const { isSignedIn, isLoaded } = useAuth()
if (!isLoaded) return null
if (!isSignedIn) {
return <Redirect href="/(auth)/sign-in" />
}
return <Stack />
}Both guards check isLoaded first and return null until Clerk initializes. This prevents a flash of the wrong content while the authentication state loads.
Create a minimal sign-in screen at app/(auth)/sign-in.tsx:
import { useState } from 'react'
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native'
import { useSignIn } from '@clerk/expo'
export default function SignIn() {
const { signIn, setActive, isLoaded } = useSignIn()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleSignIn = async () => {
if (!isLoaded || !signIn) return
try {
const result = await signIn.create({
identifier: email,
password,
})
if (result.status === 'complete') {
await setActive({ session: result.createdSessionId })
}
} catch (err: any) {
setError(err.errors?.[0]?.message || 'Sign-in failed. Check your credentials.')
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Sign In</Text>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Pressable style={styles.button} onPress={handleSignIn}>
<Text style={styles.buttonText}>Sign In</Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24 },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24, textAlign: 'center' },
error: { color: '#ef4444', marginBottom: 12, textAlign: 'center' },
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 14,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: '#6c47ff',
borderRadius: 8,
padding: 14,
alignItems: 'center',
marginTop: 4,
},
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
})Create the authenticated home screen at app/(home)/index.tsx:
import { View, Text, Pressable, StyleSheet } from 'react-native'
import { useAuth, useUser } from '@clerk/expo'
export default function Home() {
const { signOut } = useAuth()
const { user } = useUser()
return (
<View style={styles.container}>
<Text style={styles.title}>Welcome</Text>
<Text style={styles.subtitle}>{user?.primaryEmailAddress?.emailAddress}</Text>
<Pressable style={styles.button} onPress={() => signOut()}>
<Text style={styles.buttonText}>Sign Out</Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24 },
title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center' },
subtitle: { fontSize: 16, color: '#6b7280', textAlign: 'center', marginTop: 8, marginBottom: 32 },
button: {
backgroundColor: '#ef4444',
borderRadius: 8,
padding: 14,
alignItems: 'center',
},
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
})At this point, you have a working Expo app with Clerk email/password sign-in. Run npx expo start to verify everything works before adding biometric login.
Adding biometric login with Clerk's useLocalCredentials
This is the core section of the tutorial. Clerk's useLocalCredentials() hook handles the entire biometric credential flow — storing credentials, verifying biometrics, and signing in — through a single API.
How useLocalCredentials works
The flow has three stages:
- First sign-in: User signs in with email and password. The app offers to store credentials behind a biometric gate.
- Credential storage:
setCredentials()encrypts the email and password in the device's secure enclave (iOS Keychain / Android Keystore), protected by biometric authentication. - Subsequent sign-ins: On next launch, the app detects stored credentials and shows a biometric sign-in button. The user taps it, verifies with Face ID or fingerprint, and the stored credentials are retrieved and sent to Clerk to complete the sign-in.
Import the hook from @clerk/expo/local-credentials:
import { useLocalCredentials } from '@clerk/expo/local-credentials'The hook returns:
- Name
hasCredentials- Type
boolean- Description
Whether any credentials are stored on this device
- Name
userOwnsCredentials- Type
boolean- Description
Whether stored credentials belong to the current signed-in user. Always
falsewhen no user is signed in.
- Name
biometricType- Type
'face-recognition' | 'fingerprint' | null- Description
The type of biometric hardware available, or
nullif none
- Name
setCredentials- Type
(params) => Promise<void>- Description
Stores credentials behind biometric gate. Accepts
{ identifier: string, password: string }.
- Name
clearCredentials- Type
() => Promise<void>- Description
Removes stored credentials from the device
- Name
authenticate- Type
() => Promise<SignInResource>- Description
Triggers biometric prompt, retrieves stored credentials, and performs a password sign-in
Check biometric availability
Before offering to store credentials, verify that the device has biometric hardware and that the user has enrolled at least one biometric. Two functions from expo-local-authentication cover this, and you will call them inline in the sign-in flow below:
hasHardwareAsync()resolves totruewhen the device has a biometric sensor (Face ID, Touch ID, or a fingerprint reader).isEnrolledAsync()resolves totruewhen the user has registered at least one biometric in their device settings.
Only offer biometric login when both return true:
import * as LocalAuthentication from 'expo-local-authentication'
// Inside an async function, such as your sign-in handler:
const hasHardware = await LocalAuthentication.hasHardwareAsync()
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
if (hasHardware && isEnrolled) {
// Safe to offer biometric login
}Handle the edge cases:
- No hardware: Hide the biometric option entirely.
- Hardware but not enrolled: Show a message suggesting the user set up Face ID or fingerprint in their device settings.
- Permission denied (iOS):
hasHardwareAsync()andisEnrolledAsync()both rest on the systemcanEvaluatePolicycheck, so after a user denies the Face ID permission dialog they returnfalseon subsequent calls. The user must re-enable Face ID for the app in Settings → Face ID & Passcode.
Store credentials after first sign-in
After a successful password sign-in, check whether biometrics are available and offer to store credentials. Update the sign-in screen to include this flow:
import { useState } from 'react'
import { View, Text, TextInput, Pressable, Alert, StyleSheet } from 'react-native'
import { useSignIn } from '@clerk/expo'
import { useLocalCredentials } from '@clerk/expo/local-credentials'
import * as LocalAuthentication from 'expo-local-authentication'
export default function SignIn() {
const { signIn, setActive, isLoaded } = useSignIn()
const { hasCredentials, biometricType, setCredentials, clearCredentials, authenticate } =
useLocalCredentials()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handlePasswordSignIn = async () => {
if (!isLoaded || !signIn) return
setLoading(true)
setError('')
try {
const result = await signIn.create({
identifier: email,
password,
})
if (result.status === 'complete') {
// Check biometric availability before activating the session
const hasHardware = await LocalAuthentication.hasHardwareAsync()
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
if (hasHardware && isEnrolled && !hasCredentials) {
// biometricType can be null until the hook resolves the device's
// biometric type, so fall back to a generic label.
const biometricLabel =
biometricType === 'face-recognition'
? 'Face ID'
: biometricType === 'fingerprint'
? 'fingerprint'
: 'biometrics'
Alert.alert(
'Enable Biometric Login',
`Sign in faster next time with ${biometricLabel}?`,
[
{
text: 'Not Now',
style: 'cancel',
onPress: () => setActive({ session: result.createdSessionId }),
},
{
text: 'Enable',
onPress: async () => {
try {
await setCredentials({ identifier: email, password })
} catch {
// User cancelled biometric enrollment or error occurred
}
await setActive({ session: result.createdSessionId })
},
},
],
{ cancelable: false },
)
} else {
await setActive({ session: result.createdSessionId })
}
}
} catch (err: any) {
setError(err.errors?.[0]?.message || 'Sign-in failed. Check your credentials.')
} finally {
setLoading(false)
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>Sign In</Text>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Pressable
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handlePasswordSignIn}
disabled={loading}
>
<Text style={styles.buttonText}>{loading ? 'Signing in...' : 'Sign In'}</Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24 },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 24, textAlign: 'center' },
error: { color: '#ef4444', marginBottom: 12, textAlign: 'center' },
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 14,
marginBottom: 12,
fontSize: 16,
},
button: {
backgroundColor: '#6c47ff',
borderRadius: 8,
padding: 14,
alignItems: 'center',
marginTop: 4,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
})The setCredentials call stores the identifier (email) and password in expo-secure-store. The password is stored with requireAuthentication: true, meaning it is protected by the device's biometric gate. The identifier is stored without biometric protection so the app can check hasCredentials without triggering a prompt.
Conclusion
In this part, we successfully set up our Expo project with Clerk authentication and securely stored the user's credentials behind a biometric gate. In Part 2, we will implement the biometric sign-in button for returning users, manage stored credentials, and explore advanced topics like passkeys, platform differences, and security best practices.
Frequently asked questions
In this series
- How to Add Face ID/Biometric Login to Your Expo+Clerk App (you are here)
- How to Add Face ID/Biometric Login to Your Expo+Clerk App - Part 2