StealThis .dev

Expo Biometric Authentication

Biometric authentication using expo-local-authentication with Face ID / Touch ID support, hardware check, fallback to passcode, and authentication flow UI.

react-native typescript expo-local-authentication
Targets: React Native

Expo Snack

Code

import React, { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Animated, Pressable, StyleSheet, Text, View } from "react-native";
import * as LocalAuthentication from "expo-local-authentication";

/* ------------------------------------------------------------------ */
/*  Types                                                              */
/* ------------------------------------------------------------------ */

type AuthStatus = "idle" | "checking" | "ready" | "authenticating" | "success" | "failed";

interface HardwareInfo {
  hasHardware: boolean;
  isEnrolled: boolean;
  supportedTypes: LocalAuthentication.AuthenticationType[];
}

/* ------------------------------------------------------------------ */
/*  Helpers                                                            */
/* ------------------------------------------------------------------ */

const AUTH_TYPE_LABELS: Record<number, string> = {
  [LocalAuthentication.AuthenticationType.FINGERPRINT]: "Fingerprint",
  [LocalAuthentication.AuthenticationType.FACIAL_RECOGNITION]: "Face ID",
  [LocalAuthentication.AuthenticationType.IRIS]: "Iris",
};

function getAuthTypeLabel(types: LocalAuthentication.AuthenticationType[]): string {
  if (types.length === 0) return "Biometric";
  return types.map((t) => AUTH_TYPE_LABELS[t] ?? "Biometric").join(" / ");
}

/* ------------------------------------------------------------------ */
/*  Lock Icon (animated)                                               */
/* ------------------------------------------------------------------ */

function LockIcon({ unlocked }: { unlocked: boolean }) {
  const animValue = React.useRef(new Animated.Value(0)).current;

  useEffect(() => {
    Animated.spring(animValue, {
      toValue: unlocked ? 1 : 0,
      useNativeDriver: true,
      tension: 50,
      friction: 7,
    }).start();
  }, [unlocked, animValue]);

  const shackleTranslateY = animValue.interpolate({
    inputRange: [0, 1],
    outputRange: [0, -8],
  });

  const scale = animValue.interpolate({
    inputRange: [0, 0.5, 1],
    outputRange: [1, 1.15, 1],
  });

  return (
    <Animated.View style={[styles.lockContainer, { transform: [{ scale }] }]}>
      {/* Shackle */}
      <Animated.View
        style={[
          styles.lockShackle,
          unlocked && styles.lockShackleOpen,
          { transform: [{ translateY: shackleTranslateY }] },
        ]}
      />
      {/* Body */}
      <View style={[styles.lockBody, unlocked && styles.lockBodySuccess]}>
        <Text style={styles.lockKeyhole}>{unlocked ? "\u2713" : "\u2022"}</Text>
      </View>
    </Animated.View>
  );
}

/* ------------------------------------------------------------------ */
/*  BiometricAuth Component                                            */
/* ------------------------------------------------------------------ */

interface BiometricAuthProps {
  onAuthenticated?: () => void;
}

function BiometricAuth({ onAuthenticated }: BiometricAuthProps) {
  const [status, setStatus] = useState<AuthStatus>("idle");
  const [hardwareInfo, setHardwareInfo] = useState<HardwareInfo | null>(null);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  /* -- Check hardware on mount -- */
  const checkHardware = useCallback(async () => {
    setStatus("checking");
    try {
      const hasHardware = await LocalAuthentication.hasHardwareAsync();
      const isEnrolled = await LocalAuthentication.isEnrolledAsync();
      const supportedTypes = await LocalAuthentication.supportedAuthenticationTypesAsync();

      setHardwareInfo({ hasHardware, isEnrolled, supportedTypes });

      if (!hasHardware) {
        setErrorMessage("This device does not support biometric authentication.");
        setStatus("failed");
      } else if (!isEnrolled) {
        setErrorMessage(
          "No biometrics enrolled. Please set up Face ID, Touch ID, or fingerprint in device settings."
        );
        setStatus("failed");
      } else {
        setStatus("ready");
      }
    } catch {
      setErrorMessage("Unable to check biometric hardware.");
      setStatus("failed");
    }
  }, []);

  useEffect(() => {
    checkHardware();
  }, [checkHardware]);

  /* -- Authenticate -- */
  const authenticate = useCallback(async () => {
    setStatus("authenticating");
    setErrorMessage(null);

    try {
      const result = await LocalAuthentication.authenticateAsync({
        promptMessage: "Authenticate to continue",
        fallbackLabel: "Use Passcode",
        disableDeviceFallback: false,
      });

      if (result.success) {
        setStatus("success");
        onAuthenticated?.();
      } else {
        setErrorMessage(
          result.error === "user_cancel"
            ? "Authentication cancelled."
            : "Authentication failed. Please try again."
        );
        setStatus("failed");
      }
    } catch {
      setErrorMessage("Authentication failed. Please try again.");
      setStatus("failed");
    }
  }, [onAuthenticated]);

  /* -- Status label -- */
  const statusLabel =
    status === "idle" || status === "checking"
      ? "Checking device capabilities\u2026"
      : status === "ready"
        ? "Authenticate to continue"
        : status === "authenticating"
          ? "Waiting for authentication\u2026"
          : status === "success"
            ? "Authentication successful"
            : (errorMessage ?? "Authentication failed");

  const authTypeLabel = hardwareInfo ? getAuthTypeLabel(hardwareInfo.supportedTypes) : "Biometric";

  return (
    <View style={styles.card}>
      {/* Lock icon */}
      <LockIcon unlocked={status === "success"} />

      {/* Status message */}
      <Text
        style={[
          styles.statusText,
          status === "success" && styles.statusSuccess,
          status === "failed" && styles.statusFailed,
        ]}
      >
        {statusLabel}
      </Text>

      {/* Supported types badge */}
      {hardwareInfo && hardwareInfo.supportedTypes.length > 0 && (
        <View style={styles.badge}>
          <Text style={styles.badgeText}>{authTypeLabel} available</Text>
        </View>
      )}

      {/* Action buttons */}
      {status === "checking" && <ActivityIndicator color="#818cf8" style={{ marginTop: 16 }} />}

      {status === "ready" && (
        <Pressable style={styles.button} onPress={authenticate}>
          <Text style={styles.buttonText}>Authenticate</Text>
        </Pressable>
      )}

      {status === "failed" && (
        <Pressable
          style={[styles.button, styles.retryButton]}
          onPress={() => {
            if (!hardwareInfo?.hasHardware || !hardwareInfo?.isEnrolled) {
              checkHardware();
            } else {
              authenticate();
            }
          }}
        >
          <Text style={styles.buttonText}>Retry</Text>
        </Pressable>
      )}

      {status === "success" && (
        <View style={styles.successBadge}>
          <Text style={styles.successBadgeText}>Access Granted</Text>
        </View>
      )}
    </View>
  );
}

/* ------------------------------------------------------------------ */
/*  Styles                                                             */
/* ------------------------------------------------------------------ */

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0f172a",
    alignItems: "center",
    justifyContent: "center",
    padding: 24,
  },
  title: {
    fontSize: 20,
    fontWeight: "700",
    color: "#f8fafc",
    marginBottom: 24,
    textAlign: "center",
  },
  card: {
    backgroundColor: "#1e293b",
    borderRadius: 20,
    padding: 32,
    alignItems: "center",
    width: "100%",
    maxWidth: 360,
    borderWidth: 1,
    borderColor: "#334155",
  },
  /* Lock */
  lockContainer: {
    alignItems: "center",
    marginBottom: 24,
  },
  lockShackle: {
    width: 36,
    height: 28,
    borderWidth: 4,
    borderColor: "#94a3b8",
    borderBottomWidth: 0,
    borderTopLeftRadius: 18,
    borderTopRightRadius: 18,
    marginBottom: -2,
  },
  lockShackleOpen: {
    borderColor: "#34d399",
    transform: [{ translateX: 6 }],
  },
  lockBody: {
    width: 52,
    height: 40,
    backgroundColor: "#475569",
    borderRadius: 8,
    alignItems: "center",
    justifyContent: "center",
  },
  lockBodySuccess: {
    backgroundColor: "#059669",
  },
  lockKeyhole: {
    color: "#f8fafc",
    fontSize: 18,
    fontWeight: "800",
  },
  /* Status */
  statusText: {
    fontSize: 16,
    color: "#cbd5e1",
    textAlign: "center",
    marginBottom: 8,
  },
  statusSuccess: {
    color: "#34d399",
    fontWeight: "600",
  },
  statusFailed: {
    color: "#f87171",
  },
  /* Badge */
  badge: {
    backgroundColor: "#312e81",
    paddingHorizontal: 12,
    paddingVertical: 4,
    borderRadius: 12,
    marginTop: 4,
    marginBottom: 16,
  },
  badgeText: {
    color: "#a5b4fc",
    fontSize: 12,
    fontWeight: "500",
  },
  /* Button */
  button: {
    backgroundColor: "#6366f1",
    paddingHorizontal: 32,
    paddingVertical: 14,
    borderRadius: 12,
    marginTop: 8,
  },
  retryButton: {
    backgroundColor: "#dc2626",
  },
  buttonText: {
    color: "#fff",
    fontSize: 16,
    fontWeight: "600",
  },
  /* Success */
  successBadge: {
    backgroundColor: "#064e3b",
    paddingHorizontal: 20,
    paddingVertical: 10,
    borderRadius: 12,
    marginTop: 12,
  },
  successBadgeText: {
    color: "#6ee7b7",
    fontSize: 14,
    fontWeight: "600",
  },
});

/* ------------------------------------------------------------------ */
/*  Demo App                                                           */
/* ------------------------------------------------------------------ */

export default function App() {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Biometric Authentication</Text>
      <BiometricAuth onAuthenticated={() => console.log("Authenticated!")} />
    </View>
  );
}

Expo Biometric Authentication

A biometric authentication component for React Native apps built with Expo. It leverages expo-local-authentication to provide Face ID, Touch ID, and fingerprint authentication with graceful fallback to device passcode.

Features

  • Hardware detection — checks whether the device supports biometric authentication before prompting.
  • Enrollment check — verifies that the user has enrolled biometrics (fingerprint, face, iris) on the device.
  • Supported types — queries which authentication methods are available (fingerprint, facial recognition, iris).
  • Authentication flow — presents a lock screen UI with an animated lock icon that transitions to unlocked on success.
  • Fallback to passcode — if biometric auth is unavailable or fails, the user can authenticate with their device passcode.
  • Retry on failure — displays a retry button so users can re-attempt authentication after a failed attempt.

Usage

Import the component and render it as a gate before protected content. The onAuthenticated callback fires when the user successfully authenticates.

import BiometricAuth from "./BiometricAuth";

<BiometricAuth onAuthenticated={() => navigateToHome()} />;

Dependencies

  • expo-local-authentication