Skip to content

Auth0 React Native Session Management with Biometrics

A step-by-step guide to secure session management in production React Native apps with Auth0, biometric authentication, and proper token lifecycle handling.

Ayhan Sipahi Ayhan Sipahi

Session management in mobile applications presents challenges that web developers rarely encounter. A mobile app has to handle background states, network interruptions, biometric authentication, and platform-specific security constraints while keeping the experience seamless. The default that holds up across React Native apps: let the Auth0 tenant own refresh token rotation, keep tokens in Keychain or Keystore behind a biometric gate, and refresh preemptively instead of reacting to a 401.

The pieces are a token manager that refreshes on a timer and on network recovery, a biometric gate over platform secure storage, an app-state hook that enforces idle timeout, and an auth context that ties them together. Each step builds on the previous one, so the code is meant to be read in order.

Prerequisites#

Before starting, make sure you have:

  • React Native development environment configured (React Native 0.78+)
  • Auth0 account with a configured application
  • iOS and Android development environments set up
  • Basic understanding of OAuth 2.0 and JWT tokens
  • Familiarity with React Native navigation and state management

Required packages:

{
  "react-native": "^0.78.0",
  "react-native-auth0": "^4.6.0",
  "react-native-biometrics": "^3.0.1",
  "react-native-keychain": "^10.0.0",
  "@react-native-async-storage/async-storage": "^1.21.0",
  "@react-native-community/netinfo": "^11.2.0",
  "react-native-background-fetch": "^4.2.0"
}

Compatibility Notes:

  • React Native New Architecture: All packages are compatible with the New Architecture (Fabric/TurboModules)
  • Expo: Compatible with Expo dev builds (requires custom development client for native dependencies)
  • iOS Background Limitations: Background fetch is limited to system-scheduled intervals and may not work reliably for token refresh

Step 1: Understanding Session Management Architecture#

Two things drive everything that follows: the states a session moves through, and the lifetime assigned to each token.

Session States and Lifecycle#

Mobile applications require sophisticated state management to handle various authentication scenarios:

// types/AuthState.ts
export enum UserState {
  UNAUTHENTICATED = 'unauthenticated',
  AUTHENTICATING = 'authenticating',
  AUTHENTICATED = 'authenticated',
  SESSION_EXPIRED = 'session_expired',
  REQUIRES_VERIFICATION = 'requires_verification',
  CHECKING_AUTH = 'checking_auth',
  LOGGING_OUT = 'logging_out'
}

export interface AuthState {
  userState: UserState;
  user: User | null;
  tokens: {
    accessToken: string | null;
    refreshToken: string | null;
    idToken: string | null;
    expiresAt: number | null;
  };
  lastActivity: number;
  biometricEnabled: boolean;
  sessionStartTime: number;
}

export interface User {
  id: string;
  email: string;
  name: string;
  picture?: string;
  emailVerified: boolean;
}

Token Architecture#

Three tokens with different lifetimes, all configured on the Auth0 tenant rather than in app code. The values below are a workable starting point for a mobile client:

  1. Access Token (15 minutes): used for API authorization
  2. Refresh Token (30 days absolute lifetime): used to obtain new access tokens
  3. ID Token (1 hour): carries user profile claims

Step 2: Setting Up Auth0 Integration#

// services/auth/AuthService.ts
import Auth0 from 'react-native-auth0';
import Config from 'react-native-config';

class AuthService {
  private auth0: Auth0;
  private readonly AUTH0_SCOPE = 'openid profile email offline_access';

  constructor() {
    this.auth0 = new Auth0({
      domain: Config.AUTH0_DOMAIN,
      clientId: Config.AUTH0_CLIENT_ID,
    });
  }

  async login(): Promise<Credentials> {
    try {
      const credentials = await this.auth0.webAuth.authorize({
        scope: this.AUTH0_SCOPE,
        audience: Config.AUTH0_AUDIENCE,
        prompt: 'login',
        // Additional parameters for mobile optimization
        parameters: {
          device: 'mobile'
        }
      });

      return this.processCredentials(credentials);
    } catch (error) {
      if (error.error === 'a0.session.user_cancelled') {
        throw new Error('User cancelled login');
      }
      throw error;
    }
  }

  async refreshTokens(refreshToken: string): Promise<Credentials> {
    try {
      // Rotation is a tenant setting; the SDK just exchanges the refresh token
      const credentials = await this.auth0.auth.refreshToken({
        refreshToken,
        scope: this.AUTH0_SCOPE
      });

      return this.processCredentials(credentials);
    } catch (error) {
      if (error.error === 'invalid_grant') {
        throw new Error('Refresh token expired or revoked');
      }
      throw error;
    }
  }

  async logout(): Promise<void> {
    // Clear Auth0 session and cookies
    await this.auth0.webAuth.clearSession();
  }

  async getUserInfo(accessToken: string): Promise<User> {
    const userInfo = await this.auth0.auth.userInfo({ token: accessToken });

    return {
      id: userInfo.sub,
      email: userInfo.email,
      name: userInfo.name || userInfo.nickname,
      picture: userInfo.picture,
      emailVerified: userInfo.email_verified
    };
  }

  private processCredentials(credentials: any): Credentials {
    return {
      accessToken: credentials.accessToken,
      refreshToken: credentials.refreshToken,
      idToken: credentials.idToken,
      expiresIn: credentials.expiresIn,
      expiresAt: Date.now() + (credentials.expiresIn * 1000),
      tokenType: credentials.tokenType,
      scope: credentials.scope
    };
  }
}

export default new AuthService();

Step 3: Implementing Secure Token Storage#

Keychain and Keystore hold the token bytes; AsyncStorage only ever sees metadata safe enough to lose.

// services/storage/SecureStorage.ts
import * as Keychain from 'react-native-keychain';
import AsyncStorage from '@react-native-async-storage/async-storage';
import CryptoJS from 'crypto-js';

class SecureStorage {
  private readonly KEYCHAIN_SERVICE = 'com.yourapp.oauth';
  private readonly KEYCHAIN_ACCESS_GROUP = 'group.com.yourapp';

  async storeTokens(tokens: TokenSet): Promise<void> {
    try {
      // Store sensitive tokens in Keychain
      await Keychain.setInternetCredentials(
        this.KEYCHAIN_SERVICE,
        'tokens',
        JSON.stringify({
          accessToken: tokens.accessToken,
          refreshToken: tokens.refreshToken,
          expiresAt: tokens.expiresAt
        }),
        {
          accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
          accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
          accessGroup: this.KEYCHAIN_ACCESS_GROUP,
          authenticationPrompt: 'Authenticate to access your account'
        }
      );

      // Store non-sensitive data in AsyncStorage
      await AsyncStorage.setItem('@auth_metadata', JSON.stringify({
        expiresAt: tokens.expiresAt,
        scope: tokens.scope,
        tokenType: tokens.tokenType
      }));
    } catch (error) {
      console.error('Failed to store tokens securely:', error);
      throw new Error('Secure storage failed');
    }
  }

  async getTokens(): Promise<TokenSet | null> {
    try {
      const credentials = await Keychain.getInternetCredentials(
        this.KEYCHAIN_SERVICE
      );

      if (!credentials) {
        return null;
      }

      const tokens = JSON.parse(credentials.password);
      const metadata = await AsyncStorage.getItem('@auth_metadata');

      return {
        ...tokens,
        ...(metadata ? JSON.parse(metadata) : {})
      };
    } catch (error) {
      console.error('Failed to retrieve tokens:', error);
      return null;
    }
  }

  async clearTokens(): Promise<void> {
    await Keychain.resetInternetCredentials(this.KEYCHAIN_SERVICE);
    await AsyncStorage.removeItem('@auth_metadata');
  }

  async hasStoredCredentials(): Promise<boolean> {
    try {
      const credentials = await Keychain.hasInternetCredentials(
        this.KEYCHAIN_SERVICE
      );
      return credentials;
    } catch {
      return false;
    }
  }
}

export default new SecureStorage();

Step 4: Implementing Biometric Authentication#

react-native-biometrics is the gate that sits in front of the storage layer above.

// services/auth/BiometricService.ts
import ReactNativeBiometrics, { BiometryTypes } from 'react-native-biometrics';
import AsyncStorage from '@react-native-async-storage/async-storage';
import SecureStorage from '../storage/SecureStorage';
import AuthService from './AuthService';

class BiometricService {
  private rnBiometrics: ReactNativeBiometrics;

  constructor() {
    this.rnBiometrics = new ReactNativeBiometrics({
      allowDeviceCredentials: true
    });
  }

  async isBiometricAvailable(): Promise<{
    available: boolean;
    biometryType: BiometryTypes | null;
  }> {
    try {
      const { available, biometryType } = await this.rnBiometrics.isSensorAvailable();
      return { available, biometryType };
    } catch (error) {
      console.error('Biometric check failed:', error);
      return { available: false, biometryType: null };
    }
  }

  async authenticateWithBiometrics(): Promise<AuthenticationResult> {
    try {
      const { available, biometryType } = await this.isBiometricAvailable();

      if (!available) {
        return {
          success: false,
          error: 'Biometric authentication not available'
        };
      }

      // Create signature for authentication
      const { success, signature } = await this.rnBiometrics.createSignature({
        promptMessage: 'Authenticate to access your account',
        payload: Date.now().toString(),
        cancelButtonText: 'Cancel'
      });

      if (!success) {
        return {
          success: false,
          error: 'Biometric authentication failed'
        };
      }

      // Retrieve stored tokens after successful authentication
      const tokens = await SecureStorage.getTokens();

      if (!tokens) {
        return {
          success: false,
          error: 'No stored credentials found'
        };
      }

      // Check if tokens need refresh
      if (this.shouldRefreshTokens(tokens)) {
        const refreshedTokens = await AuthService.refreshTokens(tokens.refreshToken);
        await SecureStorage.storeTokens(refreshedTokens);
        return { success: true, tokens: refreshedTokens };
      }

      return { success: true, tokens };
    } catch (error) {
      console.error('Biometric authentication error:', error);
      return {
        success: false,
        error: error.message || 'Unknown error occurred'
      };
    }
  }

  async enrollBiometrics(): Promise<boolean> {
    try {
      const { available } = await this.isBiometricAvailable();

      if (!available) {
        return false;
      }

      // Generate key pair for biometric enrollment
      const { publicKey } = await this.rnBiometrics.createKeys();

      // Store public key for future verification
      await AsyncStorage.setItem('@biometric_public_key', publicKey);

      return true;
    } catch (error) {
      console.error('Biometric enrollment failed:', error);
      return false;
    }
  }

  async deleteBiometricKeys(): Promise<void> {
    await this.rnBiometrics.deleteKeys();
    await AsyncStorage.removeItem('@biometric_public_key');
  }

  private shouldRefreshTokens(tokens: TokenSet): boolean {
    const REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes
    const timeUntilExpiry = tokens.expiresAt - Date.now();
    return timeUntilExpiry < REFRESH_THRESHOLD;
  }
}

export default new BiometricService();

Step 5: Building the Token Manager#

// services/auth/TokenManager.ts
import NetInfo from '@react-native-community/netinfo';
import BackgroundFetch from 'react-native-background-fetch';
import AuthService from './AuthService';
import SecureStorage from '../storage/SecureStorage';

class TokenManager {
  private refreshTimer: NodeJS.Timeout | null = null;
  private refreshPromise: Promise<void> | null = null;
  private readonly REFRESH_THRESHOLD = 5 * 60 * 1000; // 5 minutes
  private readonly MAX_RETRY_ATTEMPTS = 3;
  private readonly RETRY_DELAY_BASE = 1000; // 1 second

  async initialize(): Promise<void> {
    // Set up automatic token refresh
    await this.scheduleTokenRefresh();

    // Configure background fetch for token refresh
    await this.configureBackgroundRefresh();

    // Listen for network changes
    NetInfo.addEventListener(this.handleNetworkChange);
  }

  async scheduleTokenRefresh(): Promise<void> {
    // Clear existing timer
    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer);
      this.refreshTimer = null;
    }

    const tokens = await SecureStorage.getTokens();
    if (!tokens || !tokens.expiresAt) {
      return;
    }

    const timeUntilExpiry = tokens.expiresAt - Date.now();
    const refreshTime = Math.max(0, timeUntilExpiry - this.REFRESH_THRESHOLD);

    if (refreshTime > 0) {
      this.refreshTimer = setTimeout(() => {
        this.performTokenRefresh();
      }, refreshTime);
    } else {
      // Token needs immediate refresh
      await this.performTokenRefresh();
    }
  }

  async performTokenRefresh(): Promise<void> {
    // Prevent concurrent refresh attempts
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    this.refreshPromise = this.doRefreshWithRetry();

    try {
      await this.refreshPromise;
    } finally {
      this.refreshPromise = null;
    }
  }

  private async doRefreshWithRetry(): Promise<void> {
    let lastError: Error | null = null;

    for (let attempt = 0; attempt < this.MAX_RETRY_ATTEMPTS; attempt++) {
      try {
        // Check network connectivity
        const netInfo = await NetInfo.fetch();
        if (!netInfo.isConnected) {
          throw new Error('No network connection');
        }

        // Get current tokens
        const tokens = await SecureStorage.getTokens();
        if (!tokens || !tokens.refreshToken) {
          throw new Error('No refresh token available');
        }

        // Perform refresh
        const newTokens = await AuthService.refreshTokens(tokens.refreshToken);

        // Store new tokens
        await SecureStorage.storeTokens(newTokens);

        // Schedule next refresh
        await this.scheduleTokenRefresh();

        // Emit success event
        this.emitTokenRefreshSuccess();

        return;
      } catch (error) {
        lastError = error;

        if (error.message === 'Refresh token expired or revoked') {
          // Can't recover from this - need user to log in again
          this.handleSessionExpired();
          return;
        }

        if (attempt < this.MAX_RETRY_ATTEMPTS - 1) {
          // Calculate exponential backoff delay
          const delay = this.RETRY_DELAY_BASE * Math.pow(2, attempt);
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    }

    // All retries failed
    console.error('Token refresh failed after all attempts:', lastError);
    this.handleSessionExpired();
  }

  private async configureBackgroundRefresh(): Promise<void> {
    await BackgroundFetch.configure({
      minimumFetchInterval: 15, // 15 minutes
      forceAlarmManager: false,
      stopOnTerminate: false,
      enableHeadless: true,
      startOnBoot: true,
      requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY
    }, async (taskId) => {
      try {
        await this.performTokenRefresh();
      } catch (error) {
        console.error('Background token refresh failed:', error);
      } finally {
        BackgroundFetch.finish(taskId);
      }
    }, (taskId) => {
      console.error('Background fetch timeout');
      BackgroundFetch.finish(taskId);
    });

    // Note: iOS background fetch is severely limited and may not work reliably
    // Consider using silent push notifications for critical token refresh scenarios
  }

  private handleNetworkChange = async (state: any) => {
    if (state.isConnected && this.refreshPromise === null) {
      // Network reconnected, check if tokens need refresh
      const tokens = await SecureStorage.getTokens();
      if (tokens && this.shouldRefreshTokens(tokens)) {
        await this.performTokenRefresh();
      }
    }
  };

  private shouldRefreshTokens(tokens: TokenSet): boolean {
    const timeUntilExpiry = tokens.expiresAt - Date.now();
    return timeUntilExpiry < this.REFRESH_THRESHOLD;
  }

  private handleSessionExpired(): void {
    // Clear tokens
    SecureStorage.clearTokens();

    // Emit session expired event
    this.emitSessionExpired();
  }

  private emitTokenRefreshSuccess(): void {
    // Emit event for app to handle
    EventEmitter.emit('auth:token-refreshed');
  }

  private emitSessionExpired(): void {
    // Emit event for app to handle
    EventEmitter.emit('auth:session-expired');
  }

  cleanup(): void {
    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer);
      this.refreshTimer = null;
    }
    BackgroundFetch.stop();
  }
}

export default new TokenManager();

Step 6: Managing App State Transitions#

The idle timeout and the post-background biometric check both live here, keyed off AppState transitions.

// hooks/useAppStateAuth.ts
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import BiometricService from '../services/auth/BiometricService';
import TokenManager from '../services/auth/TokenManager';

interface AppStateAuthConfig {
  idleTimeout?: number; // milliseconds
  requireBiometricOnForeground?: boolean;
  onSessionExpired?: () => void;
  onAuthRequired?: () => void;
}

export const useAppStateAuth = (config: AppStateAuthConfig = {}) => {
  const {
    idleTimeout = 30 * 60 * 1000, // 30 minutes default
    requireBiometricOnForeground = true,
    onSessionExpired,
    onAuthRequired
  } = config;

  const appState = useRef(AppState.currentState);
  const backgroundTime = useRef<number>(0);

  useEffect(() => {
    const handleAppStateChange = async (nextAppState: AppStateStatus) => {
      // App coming to foreground
      if (
        appState.current.match(/inactive|background/) &&
        nextAppState === 'active'
      ) {
        const timeInBackground = Date.now() - backgroundTime.current;

        if (timeInBackground > idleTimeout) {
          // Session timed out
          onSessionExpired?.();
        } else if (requireBiometricOnForeground && timeInBackground > 60000) {
          // Require biometric after 1 minute in background
          const result = await BiometricService.authenticateWithBiometrics();

          if (!result.success) {
            onAuthRequired?.();
          } else {
            // Refresh tokens if needed
            await TokenManager.scheduleTokenRefresh();
          }
        } else {
          // Just ensure tokens are fresh
          await TokenManager.scheduleTokenRefresh();
        }
      }
      // App going to background
      else if (
        appState.current === 'active' &&
        nextAppState.match(/inactive|background/)
      ) {
        backgroundTime.current = Date.now();
      }

      appState.current = nextAppState;
    };

    const subscription = AppState.addEventListener('change', handleAppStateChange);

    return () => {
      subscription.remove();
    };
  }, [idleTimeout, requireBiometricOnForeground, onSessionExpired, onAuthRequired]);
};

Step 7: Implementing the Auth Context#

// contexts/AuthContext.tsx
import React, { createContext, useContext, useEffect, useReducer } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import AuthService from '../services/auth/AuthService';
import BiometricService from '../services/auth/BiometricService';
import SecureStorage from '../services/storage/SecureStorage';
import TokenManager from '../services/auth/TokenManager';
import { useAppStateAuth } from '../hooks/useAppStateAuth';
import { AuthState, UserState } from '../types/AuthState';

interface AuthContextValue {
  state: AuthState;
  login: () => Promise<void>;
  logout: () => Promise<void>;
  authenticateWithBiometrics: () => Promise<boolean>;
  enableBiometrics: () => Promise<boolean>;
  checkAuthStatus: () => Promise<void>;
}

const AuthContext = createContext<AuthContextValue | null>(null);

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

const authReducer = (state: AuthState, action: any): AuthState => {
  switch (action.type) {
    case 'SET_USER_STATE':
      return { ...state, userState: action.payload };
    case 'SET_AUTH_DATA':
      return {
        ...state,
        userState: UserState.AUTHENTICATED,
        user: action.payload.user,
        tokens: action.payload.tokens,
        sessionStartTime: Date.now(),
        lastActivity: Date.now()
      };
    case 'CLEAR_AUTH':
      return {
        ...state,
        userState: UserState.UNAUTHENTICATED,
        user: null,
        tokens: {
          accessToken: null,
          refreshToken: null,
          idToken: null,
          expiresAt: null
        },
        sessionStartTime: 0,
        lastActivity: 0
      };
    case 'SET_BIOMETRIC_ENABLED':
      return { ...state, biometricEnabled: action.payload };
    default:
      return state;
  }
};

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, {
    userState: UserState.CHECKING_AUTH,
    user: null,
    tokens: {
      accessToken: null,
      refreshToken: null,
      idToken: null,
      expiresAt: null
    },
    lastActivity: 0,
    biometricEnabled: false,
    sessionStartTime: 0
  });

  // Handle app state changes
  useAppStateAuth({
    idleTimeout: 30 * 60 * 1000,
    requireBiometricOnForeground: state.biometricEnabled,
    onSessionExpired: () => {
      dispatch({ type: 'CLEAR_AUTH' });
    },
    onAuthRequired: () => {
      dispatch({ type: 'SET_USER_STATE', payload: UserState.REQUIRES_VERIFICATION });
    }
  });

  // Check initial auth status
  useEffect(() => {
    checkAuthStatus();

    // Listen for session expired events
    const handleSessionExpired = () => {
      dispatch({ type: 'CLEAR_AUTH' });
    };

    EventEmitter.addListener('auth:session-expired', handleSessionExpired);

    return () => {
      EventEmitter.removeListener('auth:session-expired', handleSessionExpired);
    };
  }, []);

  const checkAuthStatus = async () => {
    try {
      dispatch({ type: 'SET_USER_STATE', payload: UserState.CHECKING_AUTH });

      const tokens = await SecureStorage.getTokens();

      if (!tokens || !tokens.accessToken) {
        dispatch({ type: 'CLEAR_AUTH' });
        return;
      }

      // Check if tokens are expired
      if (tokens.expiresAt && tokens.expiresAt < Date.now()) {
        // Try to refresh
        try {
          await TokenManager.performTokenRefresh();
          const newTokens = await SecureStorage.getTokens();

          if (newTokens) {
            const user = await AuthService.getUserInfo(newTokens.accessToken);
            dispatch({
              type: 'SET_AUTH_DATA',
              payload: { user, tokens: newTokens }
            });

            // Initialize token manager
            await TokenManager.initialize();
          }
        } catch {
          dispatch({ type: 'CLEAR_AUTH' });
        }
      } else {
        // Tokens are still valid
        const user = await AuthService.getUserInfo(tokens.accessToken);
        dispatch({
          type: 'SET_AUTH_DATA',
          payload: { user, tokens }
        });

        // Initialize token manager
        await TokenManager.initialize();
      }

      // Check biometric enrollment
      const { available } = await BiometricService.isBiometricAvailable();
      if (available) {
        const enrolled = await AsyncStorage.getItem('@biometric_enrolled');
        dispatch({ type: 'SET_BIOMETRIC_ENABLED', payload: enrolled === 'true' });
      }
    } catch (error) {
      console.error('Auth status check failed:', error);
      dispatch({ type: 'CLEAR_AUTH' });
    }
  };

  const login = async () => {
    try {
      dispatch({ type: 'SET_USER_STATE', payload: UserState.AUTHENTICATING });

      const credentials = await AuthService.login();
      await SecureStorage.storeTokens(credentials);

      const user = await AuthService.getUserInfo(credentials.accessToken);

      dispatch({
        type: 'SET_AUTH_DATA',
        payload: { user, tokens: credentials }
      });

      // Initialize token manager
      await TokenManager.initialize();

      // Offer biometric enrollment
      const { available } = await BiometricService.isBiometricAvailable();
      if (available && !state.biometricEnabled) {
        // Prompt user to enable biometrics
        // This would typically show a modal or navigate to settings
      }
    } catch (error) {
      dispatch({ type: 'CLEAR_AUTH' });
      throw error;
    }
  };

  const logout = async () => {
    try {
      dispatch({ type: 'SET_USER_STATE', payload: UserState.LOGGING_OUT });

      // Cleanup token manager
      TokenManager.cleanup();

      // Clear Auth0 session
      await AuthService.logout();

      // Clear stored tokens
      await SecureStorage.clearTokens();

      // Clear biometric keys if enabled
      if (state.biometricEnabled) {
        await BiometricService.deleteBiometricKeys();
      }

      dispatch({ type: 'CLEAR_AUTH' });
    } catch (error) {
      console.error('Logout error:', error);
      // Force logout even if something fails
      dispatch({ type: 'CLEAR_AUTH' });
    }
  };

  const authenticateWithBiometrics = async (): Promise<boolean> => {
    const result = await BiometricService.authenticateWithBiometrics();

    if (result.success && result.tokens) {
      const user = await AuthService.getUserInfo(result.tokens.accessToken);
      dispatch({
        type: 'SET_AUTH_DATA',
        payload: { user, tokens: result.tokens }
      });
      return true;
    }

    return false;
  };

  const enableBiometrics = async (): Promise<boolean> => {
    const enrolled = await BiometricService.enrollBiometrics();

    if (enrolled) {
      await AsyncStorage.setItem('@biometric_enrolled', 'true');
      dispatch({ type: 'SET_BIOMETRIC_ENABLED', payload: true });
      return true;
    }

    return false;
  };

  const value = {
    state,
    login,
    logout,
    authenticateWithBiometrics,
    enableBiometrics,
    checkAuthStatus
  };

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
};

Step 8: Creating Protected Routes#

The screen a user sees depends entirely on userState.

// navigation/AuthNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../contexts/AuthContext';
import { UserState } from '../types/AuthState';

// Import screens
import LoginScreen from '../screens/LoginScreen';
import BiometricPromptScreen from '../screens/BiometricPromptScreen';
import HomeScreen from '../screens/HomeScreen';
import LoadingScreen from '../screens/LoadingScreen';

const Stack = createNativeStackNavigator();

export const AuthNavigator: React.FC = () => {
  const { state } = useAuth();

  if (state.userState === UserState.CHECKING_AUTH) {
    return <LoadingScreen />;
  }

  return (
    <NavigationContainer>
      <Stack.Navigator screenOptions={{ headerShown: false }}>
        {state.userState === UserState.AUTHENTICATED ? (
          <Stack.Group>
            <Stack.Screen name="Home" component={HomeScreen} />
            {/* Add other authenticated screens */}
          </Stack.Group>
        ) : state.userState === UserState.REQUIRES_VERIFICATION ? (
          <Stack.Screen name="BiometricPrompt" component={BiometricPromptScreen} />
        ) : (
          <Stack.Screen name="Login" component={LoginScreen} />
        )}
      </Stack.Navigator>
    </NavigationContainer>
  );
};

Failure Modes and Fixes#

Token Refresh Failures#

A refresh call that comes back with invalid_grant almost always means the token was already revoked or expired, so the code below fails loud instead of letting the request die silently.

// In TokenManager
if (error.error === 'invalid_grant') {
  // Clear local tokens
  await SecureStorage.clearTokens();

  // Force re-authentication
  EventEmitter.emit('auth:session-expired');
}

Biometric Authentication Loop#

A failed biometric check should not retrigger the same prompt immediately.

class BiometricService {
  private failureCount = 0;
  private readonly MAX_FAILURES = 3;

  async authenticateWithBackoff(): Promise<AuthenticationResult> {
    if (this.failureCount >= this.MAX_FAILURES) {
      // Fall back to password authentication
      return { success: false, error: 'Max attempts exceeded' };
    }

    const result = await this.authenticateWithBiometrics();

    if (!result.success) {
      this.failureCount++;
    } else {
      this.failureCount = 0;
    }

    return result;
  }
}

Background Refresh on iOS#

Silent push notifications carry the critical updates on iOS. Background refresh alone cannot be trusted, since the OS caps how much execution time it gets in the background.

// AppDelegate.m
- (void)application:(UIApplication *)application
    didReceiveRemoteNotification:(NSDictionary *)userInfo
    fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
  // Trigger token refresh
  [RNBackgroundFetch performFetchWithCompletionHandler:completionHandler];
}

Network State Handling#

A token refresh can fail mid-request on an unstable connection. Queue it and replay once the network monitor reports the device is back online.

class NetworkAwareTokenManager {
  private refreshQueue: Array<() => Promise<void>> = [];
  private isOnline = true;

  constructor() {
    NetInfo.addEventListener(state => {
      const wasOffline = !this.isOnline;
      this.isOnline = state.isConnected;

      if (wasOffline && this.isOnline) {
        this.processQueue();
      }
    });
  }

  async queueRefresh(): Promise<void> {
    return new Promise((resolve, reject) => {
      const task = async () => {
        try {
          await this.performTokenRefresh();
          resolve();
        } catch (error) {
          reject(error);
        }
      };

      if (this.isOnline) {
        task();
      } else {
        this.refreshQueue.push(task);
      }
    });
  }

  private async processQueue(): Promise<void> {
    while (this.refreshQueue.length > 0) {
      const task = this.refreshQueue.shift();
      if (task) await task();
    }
  }
}

Hardening the Auth Flow#

Token Storage Security#

iOS and Android each need their own baseline for secure storage.

  • iOS: Keychain with kSecAccessControlBiometryCurrentSet
  • Android: Android Keystore with user authentication required

Certificate Pinning#

Certificate pinning for the Auth0 endpoints needs React Native 0.78+ and Network Security Config.

<!-- ios/YourApp/Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSPinnedDomains</key>
  <dict>
    <key>your-tenant.auth0.com</key>
    <dict>
      <key>NSIncludesSubdomains</key>
      <true/>
      <key>NSPinnedCAIdentities</key>
      <array>
        <dict>
          <key>SPKI-SHA256-BASE64</key>
          <string>YOUR_PIN_HERE</string>
        </dict>
      </array>
    </dict>
  </dict>
</dict>
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
  <domain-config>
    <domain includeSubdomains="true">your-tenant.auth0.com</domain>
    <pin-set>
      <pin digest="SHA-256">YOUR_PIN_HERE</pin>
    </pin-set>
  </domain-config>
</network-security-config>

Jailbreak/Root Detection#

import JailMonkey from 'jail-monkey';

const checkDeviceSecurity = (): SecurityStatus => {
  if (JailMonkey.isJailBroken()) {
    return { secure: false, reason: 'Device is jailbroken/rooted' };
  }

  if (JailMonkey.isDebuggedMode()) {
    return { secure: false, reason: 'Debugger detected' };
  }

  return { secure: true };
};

// Note: With React Native New Architecture, some detection methods may need updates
// Test thoroughly on target devices and OS versions

Token Rotation#

// Auth0 Dashboard > Applications > Settings > Refresh Token Rotation
{
  "rotation": {
    "enabled": true,
    "leeway": 60
  }
}

Secure Communication#

Every token operation should ride an encrypted channel.

class SecureAPIClient {
  private async makeSecureRequest(url: string, options: RequestInit): Promise<Response> {
    const tokens = await SecureStorage.getTokens();

    if (!tokens || !tokens.accessToken) {
      throw new Error('No valid access token');
    }

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${tokens.accessToken}`,
        'X-Request-ID': generateRequestId(),
        'X-App-Version': getAppVersion()
      }
    });

    if (response.status === 401) {
      // Token might be expired, try refresh
      await TokenManager.performTokenRefresh();

      // Retry request with new token
      const newTokens = await SecureStorage.getTokens();
      return fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${newTokens.accessToken}`
        }
      });
    }

    return response;
  }
}

Reducing Refresh and Storage Overhead#

Token Caching Strategy#

class TokenCache {
  private cache: Map<string, { value: any; expires: number }> = new Map();

  set(key: string, value: any, ttl: number): void {
    this.cache.set(key, {
      value,
      expires: Date.now() + ttl
    });
  }

  get(key: string): any | null {
    const item = this.cache.get(key);

    if (!item) return null;

    if (Date.now() > item.expires) {
      this.cache.delete(key);
      return null;
    }

    return item.value;
  }
}

An in-memory cache with a TTL like this avoids re-reading Keychain on every call.

Batch Token Operations#

class BatchedSecureStorage {
  private pendingWrites: Map<string, any> = new Map();
  private writeTimer: NodeJS.Timeout | null = null;

  async set(key: string, value: any): Promise<void> {
    this.pendingWrites.set(key, value);
    this.scheduleWrite();
  }

  private scheduleWrite(): void {
    if (this.writeTimer) return;

    this.writeTimer = setTimeout(() => {
      this.flushWrites();
      this.writeTimer = null;
    }, 100);
  }

  private async flushWrites(): Promise<void> {
    const writes = Array.from(this.pendingWrites.entries());
    this.pendingWrites.clear();

    await Promise.all(
      writes.map(([key, value]) =>
        Keychain.setInternetCredentials(this.SERVICE, key, value)
      )
    );
  }
}

Batching writes like this cuts down on how often the app touches Keychain.

Preemptive Token Refresh#

A token refreshed before it expires never blocks the caller on a synchronous round trip.

class PreemptiveTokenManager {
  private readonly PREEMPTIVE_REFRESH_WINDOW = 10 * 60 * 1000; // 10 minutes

  async getValidToken(): Promise<string> {
    const tokens = await SecureStorage.getTokens();

    if (!tokens) {
      throw new Error('No tokens available');
    }

    const timeUntilExpiry = tokens.expiresAt - Date.now();

    // Refresh preemptively if within window
    if (timeUntilExpiry < this.PREEMPTIVE_REFRESH_WINDOW) {
      // Don't wait for refresh to complete
      this.performTokenRefresh().catch(console.error);
    }

    return tokens.accessToken;
  }
}

Extending the Baseline#

Once the core flow is stable, a few additions round it out:

  • Multi-factor authentication (TOTP or SMS-based 2FA)
  • Device trust: fingerprinting and trusted-device management
  • Session analytics: duration, refresh patterns, and security events
  • Offline support: local token validation with a sync queue
  • Biometric fallback and change detection for long-lived sessions

Before this reaches production, configure the Auth0 tenant for mobile, set up monitoring for refresh failures, wire up error tracking and alerting, log security events, and test across the devices and OS versions the app actually targets.

Conclusion#

The default holds for most React Native clients talking to an Auth0 tenant. It survives flaky networks, long backgrounding, and revoked refresh tokens without dumping the user at a login screen for no reason.

Override it in two situations. If the app has to stay usable while fully offline for long stretches, short-lived access tokens push you into local token validation and a sync queue, which changes the storage design. And if background refresh is load-bearing rather than a convenience, iOS background fetch will not carry it: move that path to silent push notifications.

References#

Related posts