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

Mobile users expect fast, frictionless [authentication](https://clerk.com/glossary/authentication.md). Typing a password on a phone keyboard is slow, error-prone, and increasingly unnecessary. [Biometric authentication](https://clerk.com/glossary/biometric-authentication.md) — 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](https://www.biometricupdate.com/202211/cisco-report-81-percent-of-all-smartphones-have-biometrics-enabled), and the MojoAuth Passwordless Conversion Impact Report found that biometric authentication completes in an [average of 0.7 seconds](https://mojoauth.com/data-and-research-reports/passwordless-conversion-impact-report-2026/).

Authentication friction has a direct cost. Descope reports that [48% of users have abandoned a purchase](https://www.descope.com/blog/post/auth-stats-2026) due to a forgotten password, and Baymard Institute's checkout research puts the average online cart abandonment rate at [70.19%](https://baymard.com/lists/cart-abandonment-rate). When MojoAuth analyzed 523.7 million authentication events, apps that added [passwordless](https://clerk.com/glossary/passwordless-login.md) and biometric sign-in saw [login success rates jump from 67.4% to 97.2%](https://mojoauth.com/data-and-research-reports/passwordless-conversion-impact-report-2026/).

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.

| Technology                  | Version | Purpose                      |
| --------------------------- | ------- | ---------------------------- |
| Expo SDK                    | 55      | App framework                |
| `@clerk/expo`               | 3.x     | Authentication               |
| `expo-local-authentication` | 17.x    | Biometric prompt API         |
| `expo-secure-store`         | 55.x    | Encrypted credential storage |
| React Native                | 0.83    | Mobile runtime               |
| TypeScript                  | 5.x     | Language                     |

## 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()`](https://clerk.com/docs/reference/expo/native-hooks/use-local-credentials.md) hook, which handles credential storage, biometric verification, and sign-in in a single API.

### Passkeys (FIDO2/WebAuthn)

[Passkeys](https://clerk.com/glossary/passkeys.md) 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](https://fidoalliance.org/passkey-index-2025/) 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

| Feature              | Local Credentials                              | Passkeys                                       |
| -------------------- | ---------------------------------------------- | ---------------------------------------------- |
| Authentication model | Password stored locally, unlocked by biometric | Asymmetric crypto, no password                 |
| Cross-device sync    | No (device-specific)                           | Yes (iCloud/Google sync)                       |
| Phishing resistance  | Low (password still exists)                    | High (domain-bound)                            |
| Setup complexity     | Low (one hook)                                 | High (associated domains, Apple/Google config) |
| Expo SDK 55 support  | Yes                                            | No                                             |
| Clerk API            | `useLocalCredentials()`                        | `@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 — a [conceptual overview](#adding-passkey-support-with-clerk) is included later in this article.

## Prerequisites

### Development environment

- **Node.js 22 LTS** — download from [nodejs.org](https://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](https://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)

> 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](https://clerk.com/glossary/expo.md) 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](https://docs.expo.dev/develop/development-builds/introduction/) 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

```bash
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

```bash
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`

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

```json
{
  "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:

```bash
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:

```bash
npx expo run:android
```

> **Windows and Linux users:** iOS builds require macOS and Xcode. Use [EAS Build](https://docs.expo.dev/develop/development-builds/create-a-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](https://dashboard.clerk.com)
2. Select **Create application** (or use an existing one)
3. Under authentication strategies, enable **Password** (Email → Password toggle)
4. Copy your **Publishable Key** from the **API Keys** section

### Configure [environment variables](https://clerk.com/glossary/environment-variables.md)

Create a `.env` file in the project root:

```bash
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

> **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](https://clerk.com/glossary/json-web-token.md) so users stay signed in across app restarts. These tokens refresh on a 50-second interval.
> 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`:

```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`](https://clerk.com/glossary/clerkprovider.md) 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:

```text
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`:

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

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

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

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

> For a complete sign-up flow with email verification, see the [Clerk Expo quickstart](https://clerk.com/docs/quickstarts/expo.md). 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()`](https://clerk.com/docs/reference/expo/native-hooks/use-local-credentials.md) 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`:

```tsx
import { useLocalCredentials } from '@clerk/expo/local-credentials'
```

The hook returns:

| Property              | Type                                          | Description                                                                                                |
| --------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `hasCredentials`      | `boolean`                                     | Whether any credentials are stored on this device                                                          |
| `userOwnsCredentials` | `boolean`                                     | Whether stored credentials belong to the current signed-in user. Always `false` when no user is signed in. |
| `biometricType`       | `'face-recognition' | 'fingerprint' | null` | The type of biometric hardware available, or `null` if none                                                |
| `setCredentials`      | `(params) => Promise<void>`                   | Stores credentials behind biometric gate. Accepts `{ identifier: string, password: string }`.              |
| `clearCredentials`    | `() => Promise<void>`                         | Removes stored credentials from the device                                                                 |
| `authenticate`        | `() => Promise<SignInResource>`               | Triggers biometric prompt, retrieves stored credentials, and performs a password sign-in                   |

### Check biometric availability

Before showing biometric options, verify that the device has biometric hardware and that the user has enrolled at least one biometric:

```tsx
import * as LocalAuthentication from 'expo-local-authentication'

async function checkBiometricAvailability(): Promise<{
  available: boolean
  biometricTypes: LocalAuthentication.AuthenticationType[]
}> {
  const hasHardware = await LocalAuthentication.hasHardwareAsync()
  const isEnrolled = await LocalAuthentication.isEnrolledAsync()

  if (!hasHardware || !isEnrolled) {
    return { available: false, biometricTypes: [] }
  }

  const biometricTypes = await LocalAuthentication.supportedAuthenticationTypesAsync()

  return { available: true, biometricTypes }
}
```

Handle 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):** After a user cancels the Face ID permission dialog, `hasHardwareAsync()` returns `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:

```tsx
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) {
          const biometricLabel = biometricType === 'face-recognition' ? 'Face ID' : 'fingerprint'

          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.

### Implement biometric sign-in

Now add the biometric sign-in button for returning users. When the sign-in screen mounts, check `hasCredentials` and `biometricType`. If both are truthy, show a biometric sign-in option alongside the password form.

> **Do not gate the biometric sign-in button on `userOwnsCredentials` on the sign-in screen.** When no user is signed in, `userOwnsCredentials` is always `false` — the hook checks the current `user` object, which is `null` on the sign-in screen. Using it here would prevent the biometric button from ever appearing. Use `hasCredentials && biometricType` instead.
>
> Reserve `userOwnsCredentials` for **authenticated screens** like a settings page, where you need to verify the stored credentials belong to the currently signed-in user.

Here is the complete sign-in screen with biometric sign-in, password fallback, and error recovery for biometric enrollment changes:

```tsx
import { useState, useCallback } 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 biometricLabel = biometricType === 'face-recognition' ? 'Face ID' : 'Fingerprint'

  // --- Biometric sign-in ---
  const handleBiometricSignIn = useCallback(async () => {
    setLoading(true)
    setError('')

    try {
      const result = await authenticate()

      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId })
      }
    } catch (err) {
      // Biometric enrollment may have changed (new fingerprint added,
      // Face ID reset). The password stored in expo-secure-store becomes
      // inaccessible, but hasCredentials remains true because the
      // identifier key is stored without biometric protection.
      // Clear both keys to reset state.
      await clearCredentials()
      setError(
        'Your biometric settings have changed. Please sign in with your password to re-enable biometric login.',
      )
    } finally {
      setLoading(false)
    }
  }, [authenticate, clearCredentials, setActive])

  // --- Password sign-in ---
  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 session
        const hasHardware = await LocalAuthentication.hasHardwareAsync()
        const isEnrolled = await LocalAuthentication.isEnrolledAsync()

        if (hasHardware && isEnrolled && !hasCredentials) {
          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 or biometrics unavailable
                  }
                  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}

      {/* Biometric sign-in button — shown for returning users */}
      {hasCredentials && biometricType ? (
        <Pressable
          style={[styles.biometricButton, loading && styles.buttonDisabled]}
          onPress={handleBiometricSignIn}
          disabled={loading}
        >
          <Text style={styles.biometricButtonText}>Sign in with {biometricLabel}</Text>
        </Pressable>
      ) : null}

      {hasCredentials && biometricType ? (
        <Text style={styles.divider}>or sign in with password</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 with Password'}</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' },
  biometricButton: {
    backgroundColor: '#1d4ed8',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
    marginBottom: 8,
  },
  biometricButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
  divider: {
    textAlign: 'center',
    color: '#9ca3af',
    marginVertical: 16,
    fontSize: 14,
  },
  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' },
})
```

Key implementation details:

- **`authenticate()` throws on biometric enrollment changes.** When a user adds a new fingerprint or resets Face ID, the biometric-protected password becomes inaccessible. The `authenticate()` function detects the missing password and throws an error. The `catch` block calls `clearCredentials()` to clean up the orphaned identifier key, then shows the password form.
- **`hasCredentials` persists after enrollment changes.** The identifier and password are stored under separate keys. The identifier is stored without biometric protection, so `hasCredentials` remains `true` even when the password is invalidated. Without the `clearCredentials()` call in the catch block, the app would enter an infinite loop: biometric button appears → `authenticate()` throws → biometric button still appears.
- **Password form is always available.** Biometrics are a convenience layer, not a replacement for password sign-in. The password form is shown below the biometric button so users always have a fallback.

### Manage stored credentials

Create a biometric settings component for the authenticated area. This component lets signed-in users enable or disable biometric login.

> **Sign-out behavior:** Clerk does **not** automatically clear local credentials when `signOut()` is called. This is intentional — credentials persist so the user can use biometric sign-in on their next visit without re-entering their password. Do **not** call `clearCredentials()` in your sign-out handler.
>
> **Where to use `userOwnsCredentials` vs `hasCredentials`:**
>
> - **Sign-in screen (unauthenticated):** Use `hasCredentials` + `biometricType` to gate the biometric button. `userOwnsCredentials` is always `false` here.
> - **Settings screen (authenticated):** Use `userOwnsCredentials` to verify the stored credentials belong to the current user before allowing management operations.
>
> Expose `clearCredentials()` as an explicit user action in a settings screen, not as an automatic side effect of sign-out.

```tsx
import { useState } from 'react'
import {
  View,
  Text,
  TextInput,
  Switch,
  Pressable,
  Modal,
  Alert,
  StyleSheet,
  KeyboardAvoidingView,
  Platform,
} from 'react-native'
import { useUser } from '@clerk/expo'
import { useLocalCredentials } from '@clerk/expo/local-credentials'

export default function BiometricSettings() {
  const { user } = useUser()
  const { hasCredentials, userOwnsCredentials, biometricType, setCredentials, clearCredentials } =
    useLocalCredentials()

  const [loading, setLoading] = useState(false)
  const [showPasswordModal, setShowPasswordModal] = useState(false)
  const [password, setPassword] = useState('')

  if (!biometricType) {
    return (
      <View style={styles.container}>
        <Text style={styles.label}>Biometric login is not available on this device.</Text>
      </View>
    )
  }

  const biometricLabel = biometricType === 'face-recognition' ? 'Face ID' : 'Fingerprint'

  // Stored credentials belong to a different user
  if (hasCredentials && !userOwnsCredentials) {
    return (
      <View style={styles.container}>
        <Text style={styles.label}>
          Biometric login is configured for a different account on this device.
        </Text>
        <Pressable
          style={styles.clearButton}
          onPress={async () => {
            await clearCredentials()
          }}
        >
          <Text style={styles.clearButtonText}>Remove and set up for this account</Text>
        </Pressable>
      </View>
    )
  }

  const handleToggle = async (enabled: boolean) => {
    if (enabled) {
      setPassword('')
      setShowPasswordModal(true)
    } else {
      setLoading(true)
      try {
        await clearCredentials()
      } catch {
        Alert.alert('Error', 'Could not disable biometric login.')
      } finally {
        setLoading(false)
      }
    }
  }

  const handleSubmitPassword = async () => {
    if (!password) return

    setShowPasswordModal(false)
    setLoading(true)
    try {
      await setCredentials({
        identifier: user?.primaryEmailAddress?.emailAddress || '',
        password,
      })
    } catch {
      Alert.alert('Error', 'Could not enable biometric login. Verify your biometric settings.')
    } finally {
      setPassword('')
      setLoading(false)
    }
  }

  const handleCancelModal = () => {
    setPassword('')
    setShowPasswordModal(false)
  }

  return (
    <View style={styles.container}>
      <View style={styles.row}>
        <Text style={styles.label}>Sign in with {biometricLabel}</Text>
        <Switch value={userOwnsCredentials} onValueChange={handleToggle} disabled={loading} />
      </View>
      <Text style={styles.description}>
        {userOwnsCredentials
          ? `${biometricLabel} login is enabled. You can sign in without typing your password.`
          : `Enable ${biometricLabel} to sign in faster on this device.`}
      </Text>

      <Modal
        visible={showPasswordModal}
        transparent
        animationType="fade"
        onRequestClose={handleCancelModal}
      >
        <KeyboardAvoidingView
          behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
          style={styles.modalOverlay}
        >
          <View style={styles.modalContent}>
            <Text style={styles.modalTitle}>Enable Biometric Login</Text>
            <Text style={styles.modalMessage}>
              Enter your password to enable {biometricLabel} login:
            </Text>
            <TextInput
              style={styles.modalInput}
              placeholder="Password"
              secureTextEntry
              autoFocus
              value={password}
              onChangeText={setPassword}
              onSubmitEditing={handleSubmitPassword}
            />
            <View style={styles.modalButtons}>
              <Pressable style={styles.modalButton} onPress={handleCancelModal}>
                <Text style={styles.modalCancelText}>Cancel</Text>
              </Pressable>
              <Pressable
                style={[
                  styles.modalButton,
                  styles.modalSubmitButton,
                  !password && styles.modalSubmitButtonDisabled,
                ]}
                onPress={handleSubmitPassword}
                disabled={!password}
              >
                <Text style={styles.modalSubmitText}>Enable</Text>
              </Pressable>
            </View>
          </View>
        </KeyboardAvoidingView>
      </Modal>
    </View>
  )
}

const styles = StyleSheet.create({
  container: { padding: 16 },
  row: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 8,
  },
  label: { fontSize: 16, fontWeight: '500' },
  description: { fontSize: 14, color: '#6b7280' },
  clearButton: {
    marginTop: 12,
    padding: 12,
    backgroundColor: '#fee2e2',
    borderRadius: 8,
    alignItems: 'center',
  },
  clearButtonText: { color: '#dc2626', fontWeight: '500' },
  modalOverlay: {
    flex: 1,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  modalContent: {
    backgroundColor: '#fff',
    borderRadius: 14,
    padding: 24,
    width: '85%',
    maxWidth: 340,
  },
  modalTitle: {
    fontSize: 17,
    fontWeight: '600',
    textAlign: 'center',
  },
  modalMessage: {
    fontSize: 14,
    color: '#6b7280',
    textAlign: 'center',
    marginTop: 8,
    marginBottom: 16,
  },
  modalInput: {
    borderWidth: 1,
    borderColor: '#d1d5db',
    borderRadius: 8,
    padding: 14,
    fontSize: 16,
  },
  modalButtons: {
    flexDirection: 'row',
    marginTop: 16,
    gap: 12,
  },
  modalButton: {
    flex: 1,
    paddingVertical: 12,
    borderRadius: 8,
    alignItems: 'center',
  },
  modalCancelText: { fontSize: 16, color: '#6c47ff' },
  modalSubmitButton: { backgroundColor: '#6c47ff' },
  modalSubmitButtonDisabled: { opacity: 0.5 },
  modalSubmitText: { fontSize: 16, color: '#fff', fontWeight: '600' },
})
```

This settings component uses `userOwnsCredentials` to gate the toggle — unlike the sign-in screen, this is an authenticated context where the hook can verify credential ownership. The multi-user check (`hasCredentials && !userOwnsCredentials`) handles shared-device scenarios where one user's credentials are stored but a different user is signed in.

The password prompt uses a custom `Modal` with a `TextInput` (`secureTextEntry`) instead of `Alert.prompt`, which is iOS-only. The `Modal` approach works identically on both iOS and Android. `KeyboardAvoidingView` prevents the keyboard from covering the input, using `behavior="padding"` on iOS and `behavior="height"` on Android. The `onRequestClose` prop handles the Android hardware back button.

## Adding passkey support with Clerk

> `@clerk/expo-passkeys` v1.0.13 requires Expo SDK 53 or 54. It does not yet support Expo SDK 55 (the current version). This section covers the architecture and API so you understand the approach. For a working implementation, use Expo SDK 54 or monitor the [`@clerk/expo-passkeys` package](https://github.com/clerk/javascript/tree/main/packages/expo-passkeys) for SDK 55 support.

Passkeys are an alternative to password-based biometric login. Instead of storing a password locally, passkeys use asymmetric cryptography — no password is ever created or transmitted.

### How passkeys work in Clerk's Expo SDK

Clerk's passkey support uses a separate package:

```bash
npx expo install @clerk/expo-passkeys
```

Pass it to the `ClerkProvider`:

```tsx
import { passkeys } from '@clerk/expo-passkeys'
;<ClerkProvider
  publishableKey={publishableKey}
  tokenCache={tokenCache}
  __experimental_passkeys={passkeys}
>
  {/* App content */}
</ClerkProvider>
```

iOS passkeys require an Apple Developer account with associated domains (`webcredentials`) configured. Android passkeys require a physical device — emulators do not reliably support the Credential Manager API. Both platforms require a development build.

### Passkey API overview

Registration creates a new passkey bound to the user's account:

```tsx
// Registration — requires an authenticated user
// Note: This code requires Expo SDK 53-54 with @clerk/expo-passkeys
const { isLoaded, isSignedIn, user } = useUser()

if (!isLoaded || !isSignedIn) return

try {
  const passkey = await user.createPasskey()
  // Passkey registered successfully
} catch (err) {
  // User cancelled or platform error
}
```

Authentication uses the passkey to sign in without a password:

```tsx
// Authentication — from the sign-in screen
// Note: This code requires Expo SDK 53-54 with @clerk/expo-passkeys
const { signIn, setActive } = useSignIn()

try {
  const result = await signIn.passkey({
    flow: 'discoverable',
  })

  if (result.status === 'complete') {
    await setActive({ session: result.createdSessionId })
  }
} catch (err) {
  // User cancelled or no passkey registered
}
```

Each Clerk account supports up to 10 passkeys. Passkeys can be renamed with `passkey.update({ name })` or deleted with `passkey.delete()`.

### When to expect Expo SDK 55 support

Clerk's native AuthView component (beta, introduced in Core 3) may handle passkeys automatically in future releases. Monitor the [Clerk changelog](https://clerk.com/changelog.md) and the [`@clerk/expo-passkeys` CHANGELOG](https://github.com/clerk/javascript/blob/main/packages/expo-passkeys/CHANGELOG.md) for updates.

## Handling platform differences

### iOS-specific considerations

**Face ID vs. Touch ID detection:** The `biometricType` value from `useLocalCredentials()` returns `'face-recognition'` for Face ID and `'fingerprint'` for Touch ID. Use this to show the appropriate label in your UI.

**`NSFaceIDUsageDescription` is mandatory.** If this key is missing from `Info.plist`, the app crashes when requesting Face ID access, and Apple rejects the App Store submission. Write a clear, specific string: "Allow [App Name] to use Face ID for quick sign-in to your account."

**Simulator testing:** In the iOS Simulator, go to **Features → Face ID → Enrolled** to enable Face ID. Simulate scans with:

- **Matching Face** (keyboard shortcut: Cmd+Opt+M) — successful authentication
- **Non-matching Face** (keyboard shortcut: Cmd+Opt+N) — failed authentication

**Max attempt fallback:** After five failed biometric attempts, iOS automatically falls back to the device passcode. This is OS-level behavior that cannot be overridden by the app.

### Android-specific considerations

**Biometric strength classes:** Android categorizes biometrics into three classes:

- **Class 3 (BIOMETRIC\_STRONG)** — fingerprint, some face recognition (secure hardware required)
- **Class 2 (BIOMETRIC\_WEAK)** — some face recognition (software-based)
- **Class 1** — convenience only, not suitable for authentication

`expo-secure-store` with `requireAuthentication: true` requires **Class 3 (BIOMETRIC\_STRONG)** biometrics. This means Android face unlock on many devices — including some Samsung Galaxy models — will not work with credential storage because their face recognition is Class 2. Fingerprint always works.

> **Known Samsung issue:** On Samsung Galaxy devices running Android 14, `expo-secure-store` with `requireAuthentication` throws `ERR_SECURESTORE_AUTH_NOT_CONFIGURED` when only face recognition is enrolled (no fingerprint). If your users report this issue, inform them that fingerprint enrollment is required for biometric login on affected devices.

**`cancelLabel` requirement:** When using `authenticateAsync()` from `expo-local-authentication` with `disableDeviceFallback: true`, you **must** provide a `cancelLabel` or Android crashes. This is a known platform issue.

**Data persistence:** Android deletes `expo-secure-store` data when the app is uninstalled. iOS Keychain data persists across uninstalls. This means an iOS user may see a biometric login option after reinstalling, while an Android user will need to re-enroll.

### [Cross-platform](https://clerk.com/glossary/cross-platform-development.md) biometric label utility

Use this utility to display the appropriate biometric label and icon across platforms:

```tsx
import { Platform } from 'react-native'
import * as LocalAuthentication from 'expo-local-authentication'

type BiometricInfo = {
  available: boolean
  label: string
  type: 'face-recognition' | 'fingerprint' | 'iris' | 'none'
}

export async function getBiometricInfo(): Promise<BiometricInfo> {
  const hasHardware = await LocalAuthentication.hasHardwareAsync()
  const isEnrolled = await LocalAuthentication.isEnrolledAsync()

  if (!hasHardware || !isEnrolled) {
    return { available: false, label: 'Biometrics', type: 'none' }
  }

  const types = await LocalAuthentication.supportedAuthenticationTypesAsync()

  if (types.includes(LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION)) {
    return {
      available: true,
      label: Platform.OS === 'ios' ? 'Face ID' : 'Face Unlock',
      type: 'face-recognition',
    }
  }

  if (types.includes(LocalAuthentication.AuthenticationType.FINGERPRINT)) {
    return {
      available: true,
      label: Platform.OS === 'ios' ? 'Touch ID' : 'Fingerprint',
      type: 'fingerprint',
    }
  }

  if (types.includes(LocalAuthentication.AuthenticationType.IRIS)) {
    return { available: true, label: 'Iris Scan', type: 'iris' }
  }

  return { available: false, label: 'Biometrics', type: 'none' }
}
```

## Comparing authentication providers for biometric login

Clerk is not the only authentication provider for Expo apps. Here is how the major providers compare for biometric login support.

### Clerk

Clerk provides first-class biometric support through the [`useLocalCredentials()`](https://clerk.com/docs/guides/development/local-credentials.md) hook — a single import that handles credential storage, biometric verification, and sign-in. Passkey support is available through `@clerk/expo-passkeys` (currently requires Expo SDK 53-54). No custom native bridging is required.

### Auth0

Auth0's `react-native-auth0` SDK (v5+) includes built-in biometric credential management through `LocalAuthenticationOptions` on the `Auth0Provider`. It offers four `BiometricPolicy` modes: `default`, `always`, `session`, and `appLifecycle`. Passkeys are in Early Access for native iOS and Android but are not explicitly supported in the React Native SDK. Passkeys require a custom domain.

### Stytch

Stytch offers first-class `biometrics.register()` and `biometrics.authenticate()` methods in their React Native SDK, plus WebAuthn passkey support via `webauthn.register()` and `webauthn.authenticate()`. A dedicated Expo SDK is available (`@stytch/react-native-expo`). Stytch requires iOS 13+ and Android 6+ (Class 3 biometrics only).

### Firebase

Firebase has no dedicated biometric or passkey API. Biometric login must be implemented manually by wrapping Firebase session tokens with `expo-local-authentication` and `expo-secure-store`. A passkey feature request has been [open since July 2023](https://github.com/firebase/firebase-ios-sdk/issues/11548) with no implementation.

### Supabase

Supabase has no native biometric or passkey API. The same manual approach as Firebase is required — biometrics as a client-side gate over session tokens. Supabase uses `AsyncStorage` by default, which is unencrypted and unsuitable for credential storage. Passkey support has [126+ upvotes](https://github.com/orgs/supabase/discussions/8677) and was reported as "being planned" as of January 2026.

### AWS Cognito

AWS Amplify's React Native SDK supports TOTP and SMS [MFA](https://clerk.com/glossary/multi-factor-authentication-mfa.md), but the official docs state: "WebAuthn registration and authentication are not currently supported on React Native." Biometric gating requires manual implementation with `expo-local-authentication`.

### Provider comparison

| Feature                |   Clerk   |     Auth0    | Stytch |  Firebase  |  Supabase  | AWS Cognito |
| ---------------------- | :-------: | :----------: | :----: | :--------: | :--------: | :---------: |
| Built-in biometric API |    Yes    |      Yes     |   Yes  |     No     |     No     |      No     |
| Passkey support (RN)   | SDK 53-54 | Early Access |   Yes  |     No     |     No     |      No     |
| Expo SDK               |    Yes    |      Yes     |   Yes  |  Community |     Yes    |     Yes     |
| Setup complexity       |    Low    |    Medium    | Medium | High (DIY) | High (DIY) |  High (DIY) |
| Dev build required     |    Yes    |      Yes     |   Yes  |     Yes    |     No     |      No     |

## Security best practices

### Secure credential storage

Clerk's `useLocalCredentials()` stores credentials in `expo-secure-store`, which uses the iOS Keychain and Android EncryptedSharedPreferences backed by the hardware Keystore. Never store credentials in `AsyncStorage` — it is [unencrypted and accessible without authentication](https://reactnative.dev/docs/security).

Clerk's approach uses the platform's cryptographic binding, not a simple boolean gate. The [OWASP Mobile Application Security Testing Guide (MASTG)](https://mas.owasp.org/MASTG/) warns that "event-bound" biometric checks (a boolean true/false from `authenticateAsync`) are bypassable. By storing the password behind `requireAuthentication: true` in `expo-secure-store`, the credential is cryptographically tied to a successful biometric verification — the operating system enforces this at the hardware level.

### Handling biometric enrollment changes

When a user adds a new fingerprint or resets Face ID, credentials stored with biometric protection become inaccessible:

- **iOS:** The Keychain item protected with `biometryCurrentSet` is silently invalidated when biometric enrollment changes. `SecItemCopyMatching` returns `errSecItemNotFound`, and `expo-secure-store` returns `null`.
- **Android:** The Android Keystore throws `KeyPermanentlyInvalidatedException` internally. `expo-secure-store` catches this in `getItemImpl` and returns `null`.

At the Clerk API level, `authenticate()` detects the missing password and **throws an error** rather than returning `null`. Your code must use **try/catch** (not null-checks), call `clearCredentials()` to clean up, prompt for password sign-in, and then call `setCredentials()` to re-store credentials under the new biometric enrollment. See the [complete sign-in screen code](#implement-biometric-sign-in) for the full implementation pattern.

### Fallback authentication

Always provide a password sign-in fallback. Biometrics can be unavailable for many reasons:

- No biometric hardware on the device
- Biometrics not enrolled in device settings
- User denied Face ID permission (iOS)
- Biometric enrollment changed (credentials invalidated)
- Hardware damage

Show the password form by default, with biometric sign-in as the enhanced option — not the only option.

### Data persistence asymmetry

- **iOS:** Keychain data persists after app uninstall. A returning user may see the biometric login option after reinstalling.
- **Android:** EncryptedSharedPreferences data is deleted on app uninstall. The user must re-enroll biometric login after reinstalling.

Handle both cases gracefully. On iOS, if `hasCredentials` is `true` but the stored password no longer matches the user's current password (they changed it), `authenticate()` will throw a Clerk API error. Catch it, clear credentials, and prompt for password sign-in.

## Troubleshooting common issues

### "FaceID is available but has not been configured"

**Cause:** Running in Expo Go instead of a development build, or the `NSFaceIDUsageDescription` key is missing from `Info.plist`.

**Fix:** Create a development build with `npx expo run:ios`. Verify that the `expo-local-authentication` plugin is in `app.json` with a `faceIDPermission` string.

### Biometric prompt not appearing

**Causes:**

1. Biometrics not enrolled in device or simulator settings
2. `NSFaceIDUsageDescription` missing from config
3. User previously denied Face ID permission — `hasHardwareAsync()` returns `false` after denial on iOS
4. Proguard optimization in Android production builds can break the biometric prompt

**Fix:** Check enrollment (simulator: Features → Face ID → Enrolled). Verify plugin config. For iOS permission denial, the user must re-enable in device Settings. For Android production builds, add Proguard keep rules for `androidx.biometric`.

### Credentials not persisting across app restarts

**Causes:**

1. `expo-secure-store` not properly installed — run `npx expo install expo-secure-store` and rebuild
2. Android: data deleted on app uninstall (this is expected behavior, not a bug)
3. Biometric enrollment changed, invalidating stored credentials

**Fix:** Verify installation, rebuild with `npx expo run:ios` or `npx expo run:android`. Handle invalidation gracefully with the try/catch pattern shown in the [biometric sign-in section](#implement-biometric-sign-in).

### Android crash with disableDeviceFallback

**Cause:** When using `authenticateAsync({ disableDeviceFallback: true })` from `expo-local-authentication`, Android requires a `cancelLabel` string. Omitting it causes a crash.

**Fix:** Always provide `cancelLabel` when disabling the device fallback:

```tsx
await LocalAuthentication.authenticateAsync({
  disableDeviceFallback: true,
  cancelLabel: 'Cancel',
  promptMessage: 'Verify your identity',
})
```

### Samsung face recognition not working with SecureStore

**Cause:** Samsung face recognition is classified as BIOMETRIC\_WEAK (Class 2). `expo-secure-store` with `requireAuthentication: true` requires BIOMETRIC\_STRONG (Class 3).

**Fix:** Inform users that fingerprint enrollment is required for biometric login on affected Samsung devices. You can detect this by checking if `authenticateAsync` succeeds but `setCredentials` fails.

### Android emulator fingerprint enrollment

To enroll fingerprints in the Android emulator:

1. Open the emulator's **Settings → Security → Fingerprint**
2. Follow the enrollment flow (use the extended controls fingerprint button)
3. Alternatively, use ADB: `adb -e emu finger touch 1`

Note that passkeys do **not** work in the Android emulator — a physical device is required.

## Frequently asked questions

## FAQ

### Does biometric authentication work with Expo Go?

No. 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 created with `npx expo run:ios` or `npx expo run:android`.

### What is the difference between biometric login and passkeys?

Biometric login via `useLocalCredentials` stores your password credentials on-device and uses Face ID or fingerprint to unlock them — it is a convenience layer over password-based authentication. [Passkeys](https://clerk.com/glossary/passkeys.md) use asymmetric cryptography (FIDO2/WebAuthn) for truly passwordless authentication and can sync across devices via iCloud Keychain or Google Password Manager.

### Does Clerk support biometric authentication on Android?

Yes. Clerk's `useLocalCredentials()` hook works on both iOS and Android. It supports Face ID and Touch ID on iOS, and fingerprint and face recognition on Android. Note that Android face recognition must be BIOMETRIC\_STRONG (Class 3) to work with `expo-secure-store` — some devices only have BIOMETRIC\_WEAK face recognition.

### Can I use both biometric login and passkeys in the same app?

Yes. They serve complementary purposes. You can offer passkeys as the primary passwordless sign-in method and biometric login via `useLocalCredentials` as a convenience for users who still use passwords. Note that `@clerk/expo-passkeys` currently requires Expo SDK 53-54.

### What happens if a user changes their biometrics (adds a new fingerprint)?

Credentials stored with biometric protection in `expo-secure-store` become inaccessible when biometric enrollment changes. Clerk's `authenticate()` function throws an error when this happens. Your app should catch the error, call `clearCredentials()`, fall back to password sign-in, and prompt the user to re-enroll biometric login.

### Is biometric data sent to Clerk's servers?

No. Biometric verification happens entirely on-device using the iOS Secure Enclave or Android Keystore. Clerk never receives or stores biometric data. The `useLocalCredentials()` hook only transmits the stored password credentials to Clerk's servers after local biometric verification succeeds.

### What iOS and Android versions are required?

For local credentials (`useLocalCredentials`), any iOS or Android version that supports biometric hardware works. For passkeys, iOS 16+ and Android 9+ are required.

### How do I test Face ID in the iOS Simulator?

In the iOS Simulator, go to **Features → Face ID → Enrolled** to enable Face ID. During testing, use **Features → Face ID → Matching Face** (Cmd+Opt+M) to simulate a successful scan or **Non-matching Face** (Cmd+Opt+N) to simulate a failure.

### What if the user's device does not support biometrics?

Always provide a fallback authentication method. Check `hasHardwareAsync()` and `isEnrolledAsync()` from `expo-local-authentication` before showing biometric options. If biometrics are unavailable, show the standard password sign-in form. The `biometricType` property from `useLocalCredentials()` returns `null` when no biometric hardware is detected.

### How does Clerk's biometric support compare to other providers?

Clerk is the only provider with a dedicated Expo hook (`useLocalCredentials`) specifically for biometric credential management. Auth0 offers built-in biometric options through `LocalAuthenticationOptions`. Stytch provides first-class `biometrics.register()` and `biometrics.authenticate()` methods. Firebase, Supabase, and AWS Cognito require manual implementation using `expo-local-authentication` and `expo-secure-store` directly.
