Skip to main content
Articles

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

Author: Roy Anger
Published: (last updated )

Welcome to Part 2 of our guide on adding Face ID and biometric login to your Expo and Clerk application. In Part 1, we set up the project and securely stored the user's credentials. In this part, we will implement the biometric sign-in button for returning users, manage stored credentials, and explore advanced topics like passkeys, platform differences, security best practices, and troubleshooting.

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.

Warning

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:

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)

  // 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'

  // --- 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.

Important

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.

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

Important

Passkeys in Expo are provided by the @clerk/expo-passkeys package. This section is a conceptual overview of the architecture and API; for complete, copy-paste setup and the authoritative list of supported Expo SDK versions, follow Clerk's Expo passkeys guide.

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

Install the @clerk/expo-passkeys package. It ships the native module and registers as an optional peer dependency of @clerk/expo:

npx expo install @clerk/expo-passkeys

Import the passkeys object from the @clerk/expo/passkeys subpath and pass it to your ClerkProvider:

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:

// Registration — requires an authenticated user
// Requires @clerk/expo-passkeys and a development build
const { isLoaded, isSignedIn, user } = useUser()

if (!isLoaded || !isSignedIn) return

try {
  await user.createPasskey()
  // Passkey registered and bound to the user's account
} catch (err) {
  // User cancelled or platform error
}

Authentication uses the passkey to sign in without a password:

// Authentication — from the sign-in screen
// Requires @clerk/expo-passkeys and a development build
const { isLoaded, signIn } = useSignIn()

if (!isLoaded || !signIn) return

try {
  await signIn.passkey({ flow: 'discoverable' })

  // signIn.passkey() updates the sign-in in place. Read its status, then
  // finalize to activate the session and route the user.
  if (signIn.status === 'complete') {
    await signIn.finalize({
      navigate: async ({ session }) => {
        // Navigate to your protected route now that the session is active
      },
    })
  }
} 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().

Full setup and further reading

For complete step-by-step configuration — associated domains, app.json plugins, and the registration and sign-in screens — follow Clerk's Expo passkeys reference and the custom passkey flow guide. Clerk's native AuthView component (beta, introduced in Core 3) may also streamline passkey sign-in in future releases; monitor the Clerk changelog 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.

Warning

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 biometric label utility

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

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() hook — a single import that handles credential storage, biometric verification, and sign-in. Passkey support is available through @clerk/expo-passkeys on current Expo SDKs (53 through 56). 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 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 and was reported as "being planned" as of January 2026.

AWS Cognito

AWS Amplify's React Native SDK supports TOTP and SMS MFA, 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

FeatureClerkAuth0StytchFirebaseSupabaseAWS Cognito
Built-in biometric API
Passkey support (RN)ExperimentalEarly Access
Expo SDKCommunity
Setup complexityLowMediumMediumHigh (DIY)High (DIY)High (DIY)
Dev build required

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.

Clerk's approach uses the platform's cryptographic binding, not a simple boolean gate. The OWASP Mobile Application Security Testing Guide (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 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.

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:

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.

Conclusion

In this part, we completed the biometric login implementation by adding the sign-in button for returning users and providing a settings interface to manage stored credentials. We also explored passkey support, platform-specific considerations, and security best practices to ensure a robust and user-friendly authentication experience.

Frequently asked questions

In this series

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