React Native Authentication with Expo: Secure Storage, Biometrics, and Protected Routes

Build a complete React Native auth system with Expo SDK 55. Covers secure token storage with expo-secure-store, biometric unlock with Face ID and fingerprint, and declarative route protection using Expo Router Stack.Protected.

Why Authentication Is the Foundation of Every React Native Expo App

Authentication is probably the single most critical system in any mobile app. Get it wrong, and you're exposing user data, inviting session hijacking, and — honestly — eroding trust faster than you can ship a hotfix. Get it right, though, and your users get seamless, secure access across devices and sessions without thinking twice about it.

In the React Native ecosystem, authentication workflows have matured a lot with the release of Expo SDK 55. We now have first-class support for encrypted token storage, biometric verification, and declarative route protection — all baked into the Expo toolchain.

So, let's build a complete auth system together.

We're going to cover three essential pillars: storing tokens securely with expo-secure-store, adding biometric unlock with expo-local-authentication, and protecting routes declaratively using Expo Router's Stack.Protected. By the end, you'll have a production-ready auth flow where the app boots, checks SecureStore for a saved token, prompts biometrics if one exists, and redirects unauthenticated users to a sign-in screen — all without a single imperative redirect.

Prerequisites and Project Setup

Before we dive in, make sure your environment is set up correctly. We're targeting Expo SDK 55, which ships with React Native 0.83.2 and React 19.2. One important note: SDK 55 has dropped the Legacy Architecture entirely. New Architecture is the only option now, and the newArchEnabled flag has been removed from app.json.

Required Tools and Versions

  • Node.js 18+ (LTS recommended)
  • Expo CLI (included with the expo package)
  • Expo SDK 55 (expo@^55.0.0)
  • A physical device or development build (biometrics don't work in Expo Go)
  • Xcode 16.1+ for iOS builds, Android API level 36 for Android builds

Installing Required Packages

Create a new project and install the three packages we need:

# Create a new Expo project with the tabs template
npx create-expo-app@latest my-auth-app --template tabs

cd my-auth-app

# Install authentication dependencies
npx expo install expo-secure-store expo-local-authentication expo-router

Since biometric auth requires native modules that aren't available in Expo Go, you'll need a development build:

# Create a development build
npx expo run:ios
# or
npx expo run:android

Building the Authentication Context

The backbone of our auth system is a React context that manages authentication state globally. We're using useReducer for predictable state transitions and exposing methods for signing in, signing out, and checking the current session.

I've found this pattern works really well in practice — it keeps things centralized and predictable, which matters a lot when you're debugging auth issues at 2 AM.

Defining the Auth State and Reducer

Create a file at context/AuthContext.tsx:

import React, { createContext, useContext, useReducer, useEffect, useCallback } from 'react';
import * as SecureStore from 'expo-secure-store';

type AuthState = {
  isLoading: boolean;
  isSignedIn: boolean;
  userToken: string | null;
};

type AuthAction =
  | { type: 'RESTORE_TOKEN'; token: string | null }
  | { type: 'SIGN_IN'; token: string }
  | { type: 'SIGN_OUT' };

type AuthContextType = AuthState & {
  signIn: (token: string) => Promise<void>;
  signOut: () => Promise<void>;
};

const AuthContext = createContext<AuthContextType | undefined>(undefined);

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'RESTORE_TOKEN':
      return {
        ...state,
        userToken: action.token,
        isSignedIn: action.token !== null,
        isLoading: false,
      };
    case 'SIGN_IN':
      return {
        ...state,
        userToken: action.token,
        isSignedIn: true,
        isLoading: false,
      };
    case 'SIGN_OUT':
      return {
        ...state,
        userToken: null,
        isSignedIn: false,
        isLoading: false,
      };
    default:
      return state;
  }
}

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, {
    isLoading: true,
    isSignedIn: false,
    userToken: null,
  });

  useEffect(() => {
    const restoreToken = async () => {
      let token: string | null = null;
      try {
        token = await SecureStore.getItemAsync('userToken');
      } catch (e) {
        console.warn('Failed to restore token from SecureStore:', e);
      }
      dispatch({ type: 'RESTORE_TOKEN', token });
    };
    restoreToken();
  }, []);

  const signIn = useCallback(async (token: string) => {
    await SecureStore.setItemAsync('userToken', token);
    dispatch({ type: 'SIGN_IN', token });
  }, []);

  const signOut = useCallback(async () => {
    await SecureStore.deleteItemAsync('userToken');
    dispatch({ type: 'SIGN_OUT' });
  }, []);

  return (
    <AuthContext.Provider value={{ ...state, signIn, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

The RESTORE_TOKEN action fires on app boot and checks SecureStore for an existing token. That isLoading flag is important — it prevents the UI from flashing between auth states during the initial check.

Secure Token Storage with expo-secure-store

One of the most common mistakes I see in React Native apps is storing authentication tokens in AsyncStorage. Let's talk about why that's a bad idea and how expo-secure-store fixes it.

Why AsyncStorage Is Not Safe for Tokens

AsyncStorage stores data as plain text — an unencrypted SQLite database on Android and an unencrypted plist file on iOS. On a rooted Android device or a jailbroken iPhone, any app (or anyone with physical access) can read those files directly.

Your authentication tokens stored in AsyncStorage are basically one compromised device away from a full account takeover. Not great.

How expo-secure-store Works Under the Hood

expo-secure-store encrypts data at the platform level before writing it to disk:

  • iOS: Uses the iOS Keychain with kSecClassGenericPassword. Data is encrypted by the Secure Enclave and tied to your app's bundle identifier. Keychain items can actually persist across app reinstallation on iOS unless you explicitly configure otherwise.
  • Android: Uses Android Keystore-backed encrypted SharedPreferences. The encryption keys live in hardware-backed storage (TEE or StrongBox) when available, making extraction virtually impossible without the device's lock screen credentials.

API Walkthrough

The API is refreshingly straightforward. All methods come in both async and synchronous variants, though I'd recommend sticking with the async versions for better UI responsiveness:

import * as SecureStore from 'expo-secure-store';

// Store a value
await SecureStore.setItemAsync('userToken', 'eyJhbGciOiJSUzI1NiIs...');

// Retrieve a value (returns string | null)
const token = await SecureStore.getItemAsync('userToken');

// Delete a value
await SecureStore.deleteItemAsync('userToken');

// Check availability on the current device
const isAvailable = await SecureStore.isAvailableAsync();

You can also pass a SecureStoreOptions object to control accessibility levels on iOS:

await SecureStore.setItemAsync('userToken', token, {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});

The WHEN_UNLOCKED_THIS_DEVICE_ONLY option means the token is only readable when the device is unlocked and won't migrate to a new device during backup restoration. This is the most secure option for authentication tokens.

Important Caveats

  • Size limits: Some iOS versions reject values larger than about 2048 bytes. JWTs typically stay well under this, but don't try to store full user profiles in here.
  • iOS Keychain persistence: By default, Keychain items persist across app uninstalls and reinstalls. Use the THIS_DEVICE_ONLY accessibility variants, and be aware that a user who reinstalls your app might still have an old token hanging around in the Keychain.
  • Android Auto Backup: The expo-secure-store config plugin automatically excludes its encrypted SharedPreferences from Android Auto Backup. Backed-up encrypted values can't be decrypted on a different device, which would cause failures if they weren't excluded.

Adding Biometric Authentication

Biometric authentication adds a second layer of protection: even if someone gets hold of the device, they can't access the app without the owner's fingerprint or face. The expo-local-authentication library gives us a unified API for Face ID, Touch ID on iOS, and BiometricPrompt on Android.

Configuring Face ID Permissions

On iOS, you have to declare why your app needs Face ID access. Without this entry in your app.json, the OS will silently block the Face ID sensor and fall back to PIN entry (which is a confusing experience for users):

{
  "expo": {
    "ios": {
      "infoPlist": {
        "NSFaceIDUsageDescription": "This app uses Face ID to securely access your account."
      }
    },
    "plugins": [
      [
        "expo-local-authentication",
        {
          "faceIDPermission": "This app uses Face ID to securely access your account."
        }
      ]
    ]
  }
}

Checking Hardware Support and Enrollment

Before prompting for biometric auth, always verify that the device actually has the hardware and that the user has enrolled at least one biometric method:

import * as LocalAuthentication from 'expo-local-authentication';

async function checkBiometricSupport(): Promise<{
  supported: boolean;
  enrolled: boolean;
  types: LocalAuthentication.AuthenticationType[];
}> {
  const hasHardware = await LocalAuthentication.hasHardwareAsync();
  const isEnrolled = await LocalAuthentication.isEnrolledAsync();
  const supportedTypes = await LocalAuthentication.supportedAuthenticationTypesAsync();

  return {
    supported: hasHardware,
    enrolled: isEnrolled,
    types: supportedTypes,
  };
}

The supportedAuthenticationTypesAsync method returns an array of AuthenticationType values: FINGERPRINT (1), FACIAL_RECOGNITION (2), or IRIS (3). A device can support multiple types at the same time.

Implementing the Authentication Prompt

The authenticateAsync method displays the system's native biometric prompt. Here's how to set it up:

const result = await LocalAuthentication.authenticateAsync({
  promptMessage: 'Verify your identity',
  fallbackLabel: 'Use passcode',
  disableDeviceFallback: false,
  cancelLabel: 'Cancel',
});

if (result.success) {
  // Biometric verification passed
  console.log('Authenticated successfully');
} else {
  // Authentication failed or was cancelled
  console.log('Authentication failed:', result.error);
}

Setting disableDeviceFallback to false (which is the default) lets the user fall back to their device PIN or password if biometric recognition fails. This matters more than you might think — biometric sensors can fail due to wet fingers, bad lighting for face recognition, or even temporary injuries.

Building the Biometric Unlock Flow

Here's the key insight: biometric verification and token retrieval should be composed together. You retrieve the token from SecureStore only after biometric verification succeeds. Here's a complete biometric unlock service:

import * as LocalAuthentication from 'expo-local-authentication';
import * as SecureStore from 'expo-secure-store';

export async function biometricUnlock(): Promise<{
  success: boolean;
  token: string | null;
  error?: string;
}> {
  // Step 1: Check if biometrics are available
  const hasHardware = await LocalAuthentication.hasHardwareAsync();
  if (!hasHardware) {
    return { success: false, token: null, error: 'NO_HARDWARE' };
  }

  const isEnrolled = await LocalAuthentication.isEnrolledAsync();
  if (!isEnrolled) {
    return { success: false, token: null, error: 'NOT_ENROLLED' };
  }

  // Step 2: Prompt for biometric authentication
  const authResult = await LocalAuthentication.authenticateAsync({
    promptMessage: 'Unlock to access your account',
    fallbackLabel: 'Use passcode',
    disableDeviceFallback: false,
  });

  if (!authResult.success) {
    return { success: false, token: null, error: authResult.error };
  }

  // Step 3: Only retrieve the token after successful verification
  const token = await SecureStore.getItemAsync('userToken');
  if (!token) {
    return { success: false, token: null, error: 'NO_TOKEN_STORED' };
  }

  return { success: true, token };
}

This function is designed to run during app startup. If the user has a stored token and biometric hardware is available, it gates access behind a biometric check before releasing the token to the auth context.

Protected Routes with Expo Router

Stack.Protected was introduced in Expo SDK 53 and it's honestly a game-changer for how Expo Router protected routes work. Instead of scattering imperative redirect logic across layout files, you declare which screens are accessible based on auth state, and the router handles everything else.

How Stack.Protected Works

The Protected component wraps one or more Screen components and takes a guard prop. When guard is true, the wrapped screens are accessible. When it's false, they're removed from the navigation tree entirely:

  • If a user tries to navigate to a guarded screen, the navigation fails silently.
  • If a user is currently on a screen whose guard flips to false, they get automatically redirected to the first available screen.
  • All history entries for removed screens are cleaned up from the navigation stack.
  • Deep links to guarded routes are blocked too — users can't bypass protection via URL schemes.

Folder Structure

Organize your route files to cleanly separate authenticated and unauthenticated screens:

app/
├── _layout.tsx          # Root layout with AuthProvider and Stack.Protected
├── sign-in.tsx          # Login screen (public)
├── (app)/
│   ├── _layout.tsx      # Tabs or nested stack for authenticated users
│   ├── index.tsx        # Home screen (protected)
│   ├── profile.tsx      # Profile screen (protected)
│   └── settings.tsx     # Settings screen (protected)

The Root Layout with Stack.Protected

Here's the complete root layout that wires together the auth context and route protection:

// app/_layout.tsx
import { Stack } from 'expo-router';
import { AuthProvider, useAuth } from '../context/AuthContext';
import { ActivityIndicator, View } from 'react-native';

function RootNavigator() {
  const { isSignedIn, isLoading } = useAuth();

  if (isLoading) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Protected guard={isSignedIn}>
        <Stack.Screen name="(app)" />
      </Stack.Protected>
      <Stack.Protected guard={!isSignedIn}>
        <Stack.Screen name="sign-in" />
      </Stack.Protected>
    </Stack>
  );
}

export default function RootLayout() {
  return (
    <AuthProvider>
      <RootNavigator />
    </AuthProvider>
  );
}

When the app launches, the router tries to open the index route (inside (app)). If the user isn't signed in, that entire group is guarded and inaccessible, so Router falls back to the first available screen — sign-in. When the user signs in and isSignedIn flips to true, the guarded screens become available and the sign-in screen becomes inaccessible simultaneously. No imperative navigation calls needed.

Advantages Over Manual Redirects

Before Stack.Protected, developers had to scatter redirect logic across layout files using useEffect and router.replace. That approach was fragile in several ways:

  • Redirect logic had to be duplicated across every protected segment's layout.
  • Deep links could bypass redirects if the timing was off.
  • The navigation tree didn't clearly communicate which routes were protected — it was all hidden in imperative code.
  • Race conditions between auth state changes and navigation transitions caused annoying screen flickers.

Stack.Protected eliminates all of this. Protection is declarative, centralized, and baked right into the navigation tree. And it works identically with Stack, Tabs, and Drawer navigators.

Putting It All Together

Now let's wire the entire auth flow into one cohesive system. Here's the complete boot sequence:

  1. App launches and AuthProvider initializes with isLoading: true.
  2. AuthProvider checks SecureStore for an existing token.
  3. If a token exists, the app prompts for biometric verification.
  4. If biometrics pass, the token is restored and the user enters the app.
  5. If no token exists, the user sees the sign-in screen.
  6. If biometrics fail, the user can retry or fall back to manual login.

Enhanced AuthProvider with Biometric Boot Flow

Update your AuthProvider to integrate biometric verification during token restoration:

// context/AuthContext.tsx — updated boot sequence
import React, { createContext, useContext, useReducer, useEffect, useCallback } from 'react';
import * as SecureStore from 'expo-secure-store';
import * as LocalAuthentication from 'expo-local-authentication';

// ... (AuthState, AuthAction, AuthContextType types remain the same)

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, {
    isLoading: true,
    isSignedIn: false,
    userToken: null,
  });

  useEffect(() => {
    const bootstrapAuth = async () => {
      let token: string | null = null;

      try {
        // Step 1: Check if we have a stored token
        token = await SecureStore.getItemAsync('userToken');

        if (token) {
          // Step 2: If token exists, check biometric availability
          const hasHardware = await LocalAuthentication.hasHardwareAsync();
          const isEnrolled = await LocalAuthentication.isEnrolledAsync();

          if (hasHardware && isEnrolled) {
            // Step 3: Prompt for biometric verification
            const authResult = await LocalAuthentication.authenticateAsync({
              promptMessage: 'Unlock to continue',
              fallbackLabel: 'Use passcode',
              disableDeviceFallback: false,
            });

            if (!authResult.success) {
              // Biometric failed — do not restore the token
              token = null;
            }
          }
          // If no biometric hardware or no enrollment, skip biometric check
          // and restore the token directly (graceful degradation)
        }
      } catch (e) {
        console.warn('Auth bootstrap failed:', e);
        token = null;
      }

      dispatch({ type: 'RESTORE_TOKEN', token });
    };

    bootstrapAuth();
  }, []);

  const signIn = useCallback(async (token: string) => {
    await SecureStore.setItemAsync('userToken', token, {
      keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    });
    dispatch({ type: 'SIGN_IN', token });
  }, []);

  const signOut = useCallback(async () => {
    await SecureStore.deleteItemAsync('userToken');
    dispatch({ type: 'SIGN_OUT' });
  }, []);

  return (
    <AuthContext.Provider value={{ ...state, signIn, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}

The Sign-In Screen

Here's a minimal but functional sign-in screen that calls the auth context's signIn method:

// app/sign-in.tsx
import { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { useAuth } from '../context/AuthContext';

export default function SignInScreen() {
  const { signIn } = useAuth();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);

  const handleSignIn = async () => {
    if (!email || !password) {
      Alert.alert('Error', 'Please enter your email and password.');
      return;
    }

    setLoading(true);
    try {
      // Replace with your actual API call
      const response = await fetch('https://api.yourapp.com/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });

      const data = await response.json();

      if (response.ok && data.token) {
        await signIn(data.token);
        // No navigation needed — Stack.Protected handles the redirect
      } else {
        Alert.alert('Login Failed', data.message || 'Invalid credentials.');
      }
    } catch (error) {
      Alert.alert('Error', 'Network error. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome Back</Text>
      <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
      />
      <TouchableOpacity
        style={styles.button}
        onPress={handleSignIn}
        disabled={loading}
      >
        <Text style={styles.buttonText}>
          {loading ? 'Signing in...' : 'Sign In'}
        </Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 32, textAlign: 'center' },
  input: {
    borderWidth: 1, borderColor: '#ccc', borderRadius: 8,
    padding: 14, marginBottom: 16, fontSize: 16,
  },
  button: {
    backgroundColor: '#007AFF', borderRadius: 8,
    padding: 16, alignItems: 'center',
  },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

The Authenticated Home Screen

// app/(app)/index.tsx
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { useAuth } from '../../context/AuthContext';

export default function HomeScreen() {
  const { signOut, userToken } = useAuth();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>Welcome to the App</Text>
      <Text style={styles.subtitle}>You are authenticated.</Text>
      <TouchableOpacity style={styles.button} onPress={signOut}>
        <Text style={styles.buttonText}>Sign Out</Text>
      </TouchableOpacity>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
  subtitle: { fontSize: 16, color: '#666', marginBottom: 32 },
  button: {
    backgroundColor: '#FF3B30', borderRadius: 8,
    paddingHorizontal: 24, paddingVertical: 14,
  },
  buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});

Complete Project Structure

my-auth-app/
├── app/
│   ├── _layout.tsx              # Root layout: AuthProvider + Stack.Protected
│   ├── sign-in.tsx              # Public sign-in screen
│   └── (app)/
│       ├── _layout.tsx          # Tabs layout for authenticated area
│       ├── index.tsx            # Home screen
│       ├── profile.tsx          # Profile screen
│       └── settings.tsx         # Settings screen
├── context/
│   └── AuthContext.tsx          # Auth state management + SecureStore + biometrics
├── app.json                     # Expo config with Face ID permission
├── package.json
└── tsconfig.json

Security Best Practices

A solid auth flow is only one piece of the security puzzle. Here are some additional practices worth following to harden your React Native Expo app.

Use OAuth 2.0 with PKCE

If you're integrating with a third-party identity provider (Google, Apple, Auth0), use the Authorization Code flow with Proof Key for Code Exchange (PKCE). This prevents authorization code interception attacks, which are especially relevant on mobile where custom URL schemes can be hijacked. The expo-auth-session package supports PKCE out of the box.

Never Hardcode API Keys or Secrets

Environment variables in React Native get bundled into the JavaScript payload, and that payload can be extracted from production builds. Use server-side proxying for API keys, and keep user-specific secrets exclusively in expo-secure-store. For non-sensitive config values, consider using Expo's expo-constants with EAS Secrets for build-time injection.

Implement Certificate Pinning

SSL/TLS pinning ensures your app only communicates with your legitimate server, preventing man-in-the-middle attacks even if a malicious CA certificate is installed on the device. Libraries like react-native-ssl-pinning or server-side certificate pinning via your networking layer can handle this.

Rotate and Validate Tokens

Access tokens should be short-lived (15 minutes is a common choice) with a longer-lived refresh token stored in SecureStore. Always check token expiration client-side before making API calls, and build a transparent refresh mechanism that swaps expired tokens without the user noticing.

async function getValidToken(): Promise<string | null> {
  const token = await SecureStore.getItemAsync('accessToken');
  if (!token) return null;

  // Decode JWT to check expiration (without verification — that's the server's job)
  const payload = JSON.parse(atob(token.split('.')[1]));
  const isExpired = payload.exp * 1000 < Date.now();

  if (isExpired) {
    const refreshToken = await SecureStore.getItemAsync('refreshToken');
    if (!refreshToken) return null;

    const response = await fetch('https://api.yourapp.com/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });

    if (!response.ok) return null;

    const data = await response.json();
    await SecureStore.setItemAsync('accessToken', data.accessToken);
    await SecureStore.setItemAsync('refreshToken', data.refreshToken);
    return data.accessToken;
  }

  return token;
}

Follow OWASP Mobile Guidelines

The OWASP Mobile Application Security Verification Standard (MASVS) is worth bookmarking. Key items include disabling screenshot capture on sensitive screens, detecting jailbroken or rooted devices, obfuscating JavaScript bundles in production, and implementing proper session timeout policies.

Frequently Asked Questions

Is AsyncStorage safe for storing authentication tokens in React Native?

No, and this is a really common misconception. AsyncStorage stores data as unencrypted plain text — an unencrypted SQLite database on Android, unencrypted plist files on iOS. Any app on a rooted or jailbroken device can read it. For authentication tokens, API keys, or any sensitive credentials, use expo-secure-store instead. It leverages the iOS Keychain and Android Keystore to encrypt data with hardware-backed keys, making extraction essentially impossible without the device's lock screen credentials.

How do I add Face ID to my React Native Expo app?

Install expo-local-authentication with npx expo install expo-local-authentication. Then add the NSFaceIDUsageDescription key to your app.json under expo.ios.infoPlist — without this, iOS will silently block Face ID access (no error, it just won't work). Use hasHardwareAsync() and isEnrolledAsync() to check device support before calling authenticateAsync(). Important: Face ID can't be tested in Expo Go. You'll need a development build via npx expo run:ios or EAS Build to test biometric features on a physical device.

What is Stack.Protected in Expo Router?

Stack.Protected is a declarative route guarding component that shipped with Expo SDK 53. It wraps Stack.Screen components and accepts a guard prop (a boolean). When guard is true, the wrapped screens are accessible. When it's false, they're removed entirely from the navigation tree — users can't reach them via links, deep links, or programmatic navigation. This replaces the older (and frankly flaky) pattern of using useEffect with router.replace for auth redirects. It also works with Tabs.Protected and Drawer.Protected.

Can I use biometric authentication in Expo Go?

Short answer: no. Face ID on iOS requires the NSFaceIDUsageDescription key in Info.plist, which Expo Go doesn't include. Fingerprint auth might partially work in Expo Go on some Android devices, but it's unreliable and not officially supported. For any real biometric flow, you need a development build using npx expo run:ios, npx expo run:android, or EAS Build. Development builds include all the native modules and permissions that expo-local-authentication needs.

How do I handle token refresh in React Native?

Use a dual-token strategy: a short-lived access token (typically 15 minutes) and a longer-lived refresh token (days or weeks), both stored in expo-secure-store. Before each API request, decode the access token's JWT payload to check its exp claim. If it's expired or close to expiring, hit your server's refresh endpoint with the refresh token to get a new pair. Store the new tokens and retry the original request. If the refresh token itself is expired or revoked, clear everything and send the user back to sign-in. Wrapping this logic in an HTTP interceptor (Axios interceptors work well for this) keeps it transparent to the rest of your app.

About the Author Editorial Team

Our team of expert writers and editors.