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.
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:
- Access Token (15 minutes): used for API authorization
- Refresh Token (30 days absolute lifetime): used to obtain new access tokens
- 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#
- Auth0 React Native SDK Quickstart (opens in new tab) - Official integration guide including token rotation and secure storage configuration
- Auth0 React Native SDK - GitHub (opens in new tab) - Source, examples, and changelog for the react-native-auth0 library
- JSON Web Tokens - Auth0 Docs (opens in new tab) - JWT structure and validation for access and refresh token management
- Get Access Tokens - Auth0 Docs (opens in new tab) - Token refresh and silent authentication patterns
- expo-local-authentication - Expo Documentation (opens in new tab) - Biometric prompt, Face ID, and Touch ID API for React Native
- Add Login to Your Expo Application - Auth0 Docs (opens in new tab) - Auth0 integration with Expo development builds and biometric fallback
- OAuth 2.0 Authorization Framework - RFC 6749 (opens in new tab) - Authorization code flow with PKCE underpinning Auth0 mobile token grants
Related posts
Step-by-step guide to adding Sentry to a React Native Expo app: SDK setup, Expo Router instrumentation, session replay, and source maps for EAS.
react-native · expo · monitoring +2
Build, secure, and deploy custom Model Context Protocol servers for internal systems in TypeScript, with authentication, monitoring, and Kubernetes deployment.
typescript · mcp · nodejs +5
Auth0 multi-audience authentication across micro frontends: token management strategies and silent authentication in React Native with WebView-based apps.
authentication · oauth2 · react-native +1
A Cognito passkey migration guide for SaaS teams on email, password, and SMS OTP: configuration, enrollment, and the four-tier recovery ladder that replaces SMS.
authentication · security · aws +2
Server-Driven UI is the mobile analog of server-side composition. The hard part is not JSON rendering but a versioned component contract that survives old app versions.
mobile · react-native · architecture