UTA DevHub
Guides

Biometric Authentication

Implementing fingerprint/face login using device hardware

GUIDE-08: Biometric Authentication Implementation Guide

Overview

This guide provides practical patterns for implementing biometric authentication (Fingerprint, Face ID) in React Native applications. It allows users to log in quickly using their device's secure hardware after an initial standard authentication.

This implementation leverages expo-local-authentication, our secureStorage wrapper (using expo-secure-store), and integrates with the core authentication flow defined in GUIDE-05.

The expected outcome is a seamless and secure secondary login method for returning users.

When To Use

Apply these patterns when:

  • You want to offer users a faster way to log back in after they have successfully authenticated at least once using standard methods (e.g., email/password, social login).
  • You need to securely store sensitive information (like credentials or refresh tokens) contingent on successful biometric verification.
  • The target platform supports biometric hardware (most modern iOS and Android devices).

Prerequisites:

  • expo-local-authentication library installed (npx expo install expo-local-authentication).
  • secureStorage helper (using expo-secure-store) configured, ideally with a setSecureItem method that utilizes hardware security features (See DOC-04).
  • Core Authentication flow implemented (GUIDE-05), providing the useAuth hook.

Alternatives:

  • Password/PIN: The primary fallback mechanism.
  • Magic Links / OTP: Different authentication methods altogether.

Implementation Patterns

This section details the core logic and the main hook used for biometric authentication.

Process Flow

useBiometricAuth Hook

This hook encapsulates interactions with expo-local-authentication and secureStorage.

// src/features/auth/hooks/useBiometricAuth.ts
import * as React from 'react';
import * as LocalAuthentication from 'expo-local-authentication';
import { secureStorage } from '@/core/security/storage/secureStorage'; // Adjust path
import { useAuth } from './useAuth'; // Import the core auth hook (**GUIDE-05**)
import { Platform, Alert } from 'react-native';
 
// Constants for storage keys
const BIOMETRIC_ENABLED_KEY = 'biometric_auth_enabled';
const BIOMETRIC_CREDENTIALS_KEY = 'biometric_credentials';
 
// Type for stored credentials (adapt based on chosen strategy)
type StoredCredentials = {
  email: string;
  password?: string; // Store password OR...
  refreshToken?: string; // ...refresh token (preferred)
};
 
export const useBiometricAuth = () => {
  const { login, isLoggingIn } = useAuth(); // Get login mutation trigger and status
 
  // State for biometric capabilities and user preference
  const [isSupported, setIsSupported] = React.useState(false);
  const [isEnrolled, setIsEnrolled] = React.useState(false);
  const [isEnabled, setIsEnabled] = React.useState(false); // User has opted-in
  const [isLoading, setIsLoading] = React.useState(false); // Loading for biometric ops
 
  // --- Capability Checks ---
  const checkSupport = React.useCallback(async () => {
    try {
      const supported = await LocalAuthentication.hasHardwareAsync();
      setIsSupported(supported);
      if (supported) {
        const enrolled = await LocalAuthentication.isEnrolledAsync();
        setIsEnrolled(enrolled);
      } else {
        setIsEnrolled(false);
      }
    } catch (error) {
      console.error('Error checking biometric support:', error);
      setIsSupported(false);
      setIsEnrolled(false);
    }
  }, []);
 
  const checkEnabledStatus = React.useCallback(async () => {
    try {
      const enabledStatus = await secureStorage.getItem(BIOMETRIC_ENABLED_KEY);
      setIsEnabled(enabledStatus === 'true');
    } catch (error) {
      console.error('Error checking biometric enabled status:', error);
      setIsEnabled(false);
    }
  }, []);
 
  // Run checks on component mount
  React.useEffect(() => {
    checkSupport();
    checkEnabledStatus();
  }, [checkSupport, checkEnabledStatus]);
 
  // --- Core Functions ---
 
  /**
   * Enables biometric login after user confirmation.
   * Stores provided credentials securely.
   * @param credentials - The credentials (e.g., email/password or email/refreshToken) to store.
   *                    Ensure these are valid at the time of calling.
   */
  const enableBiometricLogin = React.useCallback(async (credentials: StoredCredentials) => {
    if (!isSupported || !isEnrolled) {
      Alert.alert('Biometrics Unavailable', 'Biometric authentication is not supported or not enrolled on this device.');
      return false;
    }
 
    // Validate credentials input
    if (!credentials.email || (!credentials.password && !credentials.refreshToken)) {
        Alert.alert('Error', 'Cannot enable biometrics without valid credentials to store.');
        return false;
    }
 
    setIsLoading(true);
    try {
      // Prompt user to confirm identity before storing credentials
      const result = await LocalAuthentication.authenticateAsync({
        promptMessage: 'Confirm identity to enable biometric login',
      });
 
      if (result.success) {
        // Use setSecureItem to leverage hardware-backed storage if available
        const dataToStore = JSON.stringify(credentials);
        // See **DOC-04** for secureStorage implementation details
        await secureStorage.setSecureItem(BIOMETRIC_CREDENTIALS_KEY, dataToStore);
        await secureStorage.setItem(BIOMETRIC_ENABLED_KEY, 'true');
        setIsEnabled(true);
        console.log('Biometric login enabled and credentials stored.');
        return true;
      } else {
        console.log('User failed or cancelled biometric prompt during enable.');
        return false;
      }
    } catch (error) {
      console.error('Error enabling biometric login:', error);
      Alert.alert('Error', 'Could not enable biometric login. Please try again.');
      return false;
    } finally {
      setIsLoading(false);
    }
  }, [isSupported, isEnrolled]); // Dependencies: support/enrollment status
 
  /**
   * Disables biometric login and removes stored credentials.
   */
  const disableBiometricLogin = React.useCallback(async () => {
    setIsLoading(true);
    try {
      // Consider requiring biometric auth here too for security
      await secureStorage.removeItem(BIOMETRIC_CREDENTIALS_KEY);
      await secureStorage.setItem(BIOMETRIC_ENABLED_KEY, 'false'); // Or removeItem
      setIsEnabled(false);
      console.log('Biometric login disabled.');
      return true;
    } catch (error) {
      console.error('Error disabling biometric login:', error);
      Alert.alert('Error', 'Could not disable biometric login. Please try again.');
      return false;
    } finally {
      setIsLoading(false);
    }
  }, []);
 
  /**
   * Attempts to log the user in using stored biometric credentials.
   */
  const loginWithBiometrics = React.useCallback(async () => {
    if (!isSupported || !isEnrolled || !isEnabled) {
      console.log('Biometric login not available/enabled.');
      return false;
    }
 
    setIsLoading(true);
    try {
      // 1. Prompt user for biometrics
      const result = await LocalAuthentication.authenticateAsync({
        promptMessage: 'Sign in with biometrics',
        disableDeviceFallback: true, // Prevent fallback to device passcode for login
      });
 
      if (result.success) {
        // 2. Retrieve stored credentials
        // Uses secureStorage configured in **DOC-04**
        const storedData = await secureStorage.getItem(BIOMETRIC_CREDENTIALS_KEY);
        if (!storedData) {
          throw new Error('Biometric credentials not found after successful auth. Please enable it again.');
        }
        const credentials = JSON.parse(storedData) as StoredCredentials;
 
        // 3. Use credentials with the standard login flow (**GUIDE-05**)
        if (credentials.email && credentials.password) {
          await login(credentials); // Trigger useAuth login mutation
          console.log('Biometric login successful using stored credentials.');
          // Success/error is handled by the main login mutation observer
          return true; // Biometric step succeeded
        } else if (credentials.email && credentials.refreshToken) {
           // Strategy: Implement login via refresh token if backend supports it
           console.warn('Biometric login with refresh token not implemented yet.');
           // Example: await loginWithRefreshToken(credentials.refreshToken);
           throw new Error('Login with refresh token not supported yet.');
        } else {
           throw new Error('Invalid stored biometric credentials format.');
        }
      } else {
        console.log('User failed or cancelled biometric prompt during login.');
        return false;
      }
    } catch (error: any) {
      console.error('Error during biometric login:', error);
      Alert.alert('Biometric Login Failed', error.message || 'Please use your password.');
      return false;
    } finally {
      setIsLoading(false);
    }
  }, [isSupported, isEnrolled, isEnabled, login]); // Dependencies: status flags and login function
 
  // --- Return Hook API ---
  return {
    isSupported, // Does the device have biometric hardware?
    isEnrolled, // Has the user set up biometrics on the device?
    isEnabled, // Has the user opted-in within the app?
    canLoginWithBiometrics: isSupported && isEnrolled && isEnabled, // Convenience check
    isLoading, // True during biometric-specific async operations (enable/disable/auth)
    isLoggingIn, // True when the underlying useAuth().login mutation is running (**GUIDE-05**)
    checkSupport, // Function to re-check hardware/enrollment
    checkEnabledStatus, // Function to re-check opt-in status
    enableBiometricLogin, // Function to enable and store credentials
    disableBiometricLogin, // Function to disable and remove credentials
    loginWithBiometrics, // Function to attempt login
  };
};

Examples

These examples show how to integrate the useBiometricAuth hook into UI components.

Login Screen Button

Offer biometric login alongside the standard password field.

// src/features/auth/screens/LoginScreen/LoginScreen.tsx
import React from 'react';
import {
  View, Text, TouchableOpacity,
  ActivityIndicator, StyleSheet
} from 'react-native';
import { useBiometricAuth } from '@/features/auth/hooks/useBiometricAuth';
import { useAuth } from '@/features/auth/hooks/useAuth'; // **GUIDE-05** hook
// Assuming styles (colors, spacing, typography) are imported
 
export const LoginScreen = ({ navigation }) => {
  const { isLoggingIn } = useAuth(); // Get status from standard login
  const {
    canLoginWithBiometrics,
    loginWithBiometrics,
    isLoading: isBiometricLoading, // Biometric-specific loading
  } = useBiometricAuth();
 
  const handleBiometricLogin = async () => {
    await loginWithBiometrics();
    // Successful login navigation is handled by root navigator observing `isLoggedIn` state
  };
 
  // Disable buttons if either standard login or biometric check is running
  const isBusy = isLoggingIn || isBiometricLoading;
 
  return (
    <View style={styles.container}>
      {/* ... Email/Password Form (ensure fields are disabled if isBusy) ... */}
 
      {/* Conditionally render the biometric button */}
      {canLoginWithBiometrics && (
        <TouchableOpacity
          style={[styles.biometricButton, isBusy && styles.buttonDisabled]}
          onPress={handleBiometricLogin}
          disabled={isBusy}
        >
          {isBiometricLoading ? (
              <ActivityIndicator size="small" color={/* colors.primary */ '#007AFF'} />
          ) : (
            <>
              {/* Replace Text with actual Icon component */}
              <Text style={{fontSize: 24}}> बायो </Text> {/* Replace with actual icon */}
              <Text style={styles.biometricText}>Sign in with Biometrics</Text>
            </>
          )}
        </TouchableOpacity>
      )}
 
      {/* ... Footer with Sign Up link ... */}
    </View>
  );
};
 
const styles = StyleSheet.create({
    container: { flex: 1, padding: 20, justifyContent: 'center' },
    biometricButton: {
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        padding: 12,
        marginTop: 20,
        borderWidth: 1,
        borderColor: '#007AFF',
        borderRadius: 8,
    },
    buttonDisabled: {
        opacity: 0.5,
        backgroundColor: '#e0e0e0'
    },
    biometricText: {
        marginLeft: 10,
        color: '#007AFF',
        fontSize: 16,
        fontWeight: '500',
    },
    // ... other necessary styles for form, footer etc.
});

Settings Screen Toggle

Allow users to enable or disable the feature.

// src/features/settings/screens/SecuritySettingsScreen.tsx
import React from 'react';
import { View, Text, Switch, Alert, StyleSheet } from 'react-native';
import { useBiometricAuth } from '@/features/auth/hooks/useBiometricAuth';
import { useAuth } from '@/features/auth/hooks/useAuth'; // Need user context from **GUIDE-05**
 
export const SecuritySettingsScreen = () => {
  const { user } = useAuth(); // Get current user data (needed for email)
  const {
    isSupported,
    isEnrolled,
    isEnabled,
    enableBiometricLogin,
    disableBiometricLogin,
    isLoading: isBiometricLoading, // Loading state from the hook
  } = useBiometricAuth();
 
  const handleToggle = async (newValue: boolean) => {
    if (newValue) {
      // --- Enabling Biometrics ---
      if (!user?.email) {
        Alert.alert('Error', 'Cannot enable biometrics - user information unavailable.');
        return;
      }
 
      // Security Best Practice: Prompt for password before storing credentials.
      // Storing refreshToken is generally safer than password if backend supports it.
      Alert.prompt(
        'Confirm Password',
        'Please enter your current password to enable biometric login.',
        async (password) => {
          if (!password) return; // User cancelled prompt
 
          // !! WARNING: Ideally, verify password with backend before proceeding !!
          // This example proceeds directly for simplicity.
          // See "Security Considerations" section below.
          const success = await enableBiometricLogin({
            email: user.email,
            password: password, // Or pass refreshToken if using that strategy
          });
 
          if (!success) {
            // Enabling failed (e.g., user cancelled biometric prompt)
            // UI should ideally reflect this (switch might toggle back automatically
            // if `isEnabled` state didn't change, or manually revert it).
            Alert.alert('Failed', 'Could not enable biometric login.');
          }
        },
        'secure-text' // Use secure text input for password
      );
    } else {
      // --- Disabling Biometrics ---
      const success = await disableBiometricLogin();
      if (!success) {
          Alert.alert('Failed', 'Could not disable biometric login.');
          // UI might need to revert toggle state
      }
    }
  };
 
  // Don't show the toggle if hardware isn't supported or biometrics aren't enrolled
  if (!isSupported || !isEnrolled) {
    return (
      <View style={styles.container}>
        <Text style={styles.infoText}>
          Biometric login is not available.
          { !isSupported ? ' Your device does not support it.' : ' Please set up fingerprint or face recognition in your device settings.' }
        </Text>
      </View>
    );
  }
 
  // Render the toggle if supported and enrolled
  return (
    <View style={styles.container}>
      <View style={styles.row}>
        <Text style={styles.label}>Enable Biometric Login</Text>
        <Switch
          value={isEnabled} // Reflects the current opt-in state
          onValueChange={handleToggle} // Calls enable/disable logic
          disabled={isBiometricLoading} // Disable while processing
        />
      </View>
      <Text style={styles.note}>Allows you to log in using your device's fingerprint or face recognition after the initial password login.</Text>
    </View>
  );
};
 
const styles = StyleSheet.create({
    container: { flex: 1, padding: 20 },
    row: {
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center',
        marginBottom: 15,
        paddingVertical: 10,
        borderBottomWidth: 1,
        borderBottomColor: '#eee',
    },
    label: { fontSize: 16, flex: 1, marginRight: 10 }, // Allow text to wrap
    note: { fontSize: 13, color: 'gray', marginTop: -5 },
    infoText: { fontSize: 14, color: 'gray', textAlign: 'center', padding: 20 },
});

Common Challenges

  • Credential Storage Strategy: Deciding whether to store password or refreshToken is crucial. Refresh tokens are generally more secure if your backend supports re-authentication via refresh tokens. Storing passwords, even securely, increases risk.
  • SecureStore Item Invalidation: Biometric enrollment changes (adding/removing fingers/faces) or OS updates can sometimes invalidate items stored in SecureStore that require authentication. Your logic should gracefully handle cases where stored credentials (BIOMETRIC_CREDENTIALS_KEY) cannot be retrieved, prompting the user to re-enable the feature.
  • Error Handling: Provide clear feedback to the user if LocalAuthentication.authenticateAsync fails (e.g., too many attempts, hardware error, user cancellation).
  • Platform Differences: While expo-local-authentication abstracts many differences, subtle variations in prompts or behavior might exist between iOS and Android. Test thoroughly on both.
  • Enabling Flow Security: The step where credentials are stored (enableBiometricLogin) is critical. Ideally, verify the user's current password against the backend before storing it or the refresh token.

Performance Considerations

  • Minimal Overhead: Biometric checks (hasHardwareAsync, isEnrolledAsync, authenticateAsync) are generally fast native calls.
  • Secure Storage Speed: Reading/writing to SecureStore is typically quick but involves cryptographic operations.
  • Login Speed: The main benefit is avoiding password typing. The subsequent call to useAuth().login (from GUIDE-05) still involves a network request, so the overall login time depends heavily on network latency and backend response time after the biometric check succeeds.

Do's and Don'ts

  • (Do ✅) Prefer storing refresh tokens over passwords if possible. This minimizes the risk if the device's secure storage is compromised.
  • (Do ✅) Use secureStorage.setSecureItem (or configure requireAuthentication: true with SecureStore) when storing the credentials/token to bind them to successful biometric authentication. (See DOC-04).
  • (Do ✅) Always require explicit user consent and a successful biometric prompt before storing credentials (i.e., during the enableBiometricLogin flow).
  • (Don't ❌) Automatically enable biometric login or store credentials without explicit user interaction and confirmation.
  • (Do ✅) Provide a fallback to standard password login in case biometrics fail or are unavailable.
  • (Consider 🤔) Requiring biometric authentication (or password confirmation) to disable the feature for added security.
  • (Be Aware ❗) Of Keychain (iOS) and Keystore (Android) behavior regarding item persistence and invalidation, especially after OS updates or changes to device biometrics. Stored items might become inaccessible. (See DOC-04).
  • (Do ✅) Verify user identity (e.g., re-prompt for password) before storing credentials during the enableBiometricLogin flow, especially if storing passwords directly.
  • GUIDE-05: Authentication Flow Implementation Guide (Core login flow)
  • DOC-04: Security & Offline Framework Reference (SecureStorage details)

On this page