Skip to main content
Articles

How to Add Face ID/Biometric Login to Your Expo+Clerk App

Author: Roy Anger
Published: (last updated )

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.

TechnologyVersionPurpose
Expo SDK55App framework
@clerk/expo3.xAuthentication
expo-local-authentication55.xBiometric prompt API
expo-secure-store55.xEncrypted credential storage
React Native0.83Mobile runtime
TypeScript5.xLanguage

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

FeatureLocal CredentialsPasskeys
Authentication modelPassword stored locally, unlocked by biometricAsymmetric crypto, no password
Cross-device syncNo (device-specific)Yes (iCloud/Google sync)
Phishing resistanceLow (password still exists)High (domain-bound)
Setup complexityLow (one hook)High (associated domains, Apple/Google config)
Expo SDK 55 support
Clerk APIuseLocalCredentials()@clerk/expo-passkeys

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)

Important

This is the most common stumbling block. Face ID and biometric credential storage require native modules (expo-local-authentication, expo-secure-store) that are not available in Expo Go. You must use a development build for this entire tutorial.

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-app

This creates a new TypeScript Expo project with the standard file structure.

Install dependencies

npx expo install expo-local-authentication expo-secure-store @clerk/expo

Each 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, including useLocalCredentials

Note

expo-local-authentication is an optional peer dependency of @clerk/expo. You only need it if you use the useLocalCredentials() hook (imported from the subpath @clerk/expo/local-credentials). Since this tutorial uses biometrics, install it here. npm, yarn, and pnpm will not warn or fail if it is absent — the package is native-only and has no effect on Expo web projects.

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:ios

This 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:android

Tip

Windows and Linux users: iOS builds require macOS and Xcode. Use EAS Build as an alternative: npx eas build --profile development --platform ios. The free tier includes 15 iOS and 15 Android builds per month.

Testing 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

  1. Sign in to the Clerk Dashboard.
  2. Create a new application (or select an existing one).
  3. Under User & authentication, enable Password as a sign-in option.
  4. 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-here

Expo 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

Note

Token storage vs. credential storage: You will see expo-secure-store referenced in two contexts in this tutorial:

  1. Token cache (tokenCache from @clerk/expo/token-cache) — stores Clerk's session JSON Web Tokens so users stay signed in across app restarts. These tokens are short-lived — Clerk refreshes them roughly every 60 seconds.
  2. Credential storage (useLocalCredentials) — stores the user's email and password behind a biometric gate for quick re-authentication.

These are separate storage entries under different keys. The token cache handles session persistence. Credential storage handles the biometric login convenience.

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 screen

Create 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' },
})

Note

For a complete sign-up flow with email verification, see the Clerk Expo quickstart. This tutorial focuses on the biometric layer.

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:

  1. First sign-in: User signs in with email and password. The app offers to store credentials behind a biometric gate.
  2. Credential storage: setCredentials() encrypts the email and password in the device's secure enclave (iOS Keychain / Android Keystore), protected by biometric authentication.
  3. 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 false when no user is signed in.

  • Name
    biometricType
    Type
    'face-recognition' | 'fingerprint' | null
    Description

    The type of biometric hardware available, or null if 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 to true when the device has a biometric sensor (Face ID, Touch ID, or a fingerprint reader).
  • isEnrolledAsync() resolves to true when 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() and isEnrolledAsync() both rest on the system canEvaluatePolicy check, so after a user denies the Face ID permission dialog they return false on 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

  1. How to Add Face ID/Biometric Login to Your Expo+Clerk App (you are here)
  2. How to Add Face ID/Biometric Login to Your Expo+Clerk App - Part 2