React Native

Authentication Flow in React Native with Context API

Authentication Flow in React Native with Context API

Managing user authentication is essential in almost every mobile app. While libraries like Redux or Zustand are popular for state management, the built-in Context API provides everything you need for handling authentication flows in React Native. In this comprehensive guide, you’ll learn how to implement a production-ready authentication system with secure token storage, automatic token refresh, API integration, and protected navigation—all using React’s native capabilities.

Why Use Context API for Auth?

The Context API is ideal for authentication state because:

  • Built into React – No additional packages or bundle size
  • Global accessibility – Auth state available anywhere in your component tree
  • Hooks integration – Works seamlessly with useContext, useReducer, and useEffect
  • Type safety – Full TypeScript support for better developer experience
  • Testability – Easy to mock and test auth state in isolation

For more on React’s Context API, see the official React documentation.

Project Structure

We’ll organize our code using a feature-first structure:

/src
  /auth
    AuthContext.tsx       # Context provider and hooks
    authReducer.ts        # State management logic
    authService.ts        # API calls
    authTypes.ts          # TypeScript interfaces
  /screens
    LoginScreen.tsx
    RegisterScreen.tsx
    HomeScreen.tsx
    ProfileScreen.tsx
  /navigation
    AppNavigator.tsx
    AuthNavigator.tsx
  /components
    LoadingScreen.tsx
    FormInput.tsx
  /utils
    storage.ts            # Secure storage utilities
    api.ts                # Axios instance with interceptors

Type Definitions

Start by defining TypeScript interfaces for type safety:

// src/auth/authTypes.ts
export interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
  createdAt: string;
}

export interface AuthTokens {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
}

export interface AuthState {
  isAuthenticated: boolean;
  isLoading: boolean;
  user: User | null;
  tokens: AuthTokens | null;
  error: string | null;
}

export type AuthAction =
  | { type: 'AUTH_START' }
  | { type: 'AUTH_SUCCESS'; payload: { user: User; tokens: AuthTokens } }
  | { type: 'AUTH_FAILURE'; payload: string }
  | { type: 'LOGOUT' }
  | { type: 'RESTORE_TOKEN'; payload: { user: User; tokens: AuthTokens } }
  | { type: 'UPDATE_USER'; payload: User }
  | { type: 'REFRESH_TOKENS'; payload: AuthTokens };

export interface AuthContextType extends AuthState {
  login: (email: string, password: string) => Promise<void>;
  register: (name: string, email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  updateProfile: (updates: Partial<User>) => Promise<void>;
}

export interface LoginRequest {
  email: string;
  password: string;
}

export interface RegisterRequest {
  name: string;
  email: string;
  password: string;
}

export interface AuthResponse {
  user: User;
  accessToken: string;
  refreshToken: string;
  expiresIn: number;
}

Secure Storage Utilities

Use secure storage for sensitive data like tokens. Never store tokens in plain AsyncStorage:

// src/utils/storage.ts
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import { AuthTokens, User } from '../auth/authTypes';

const TOKENS_KEY = 'auth_tokens';
const USER_KEY = 'auth_user';

// Use SecureStore on mobile, AsyncStorage on web (with encryption in production)
const secureStorage = {
  async setItem(key: string, value: string): Promise<void> {
    if (Platform.OS === 'web') {
      await AsyncStorage.setItem(key, value);
    } else {
      await SecureStore.setItemAsync(key, value);
    }
  },

  async getItem(key: string): Promise<string | null> {
    if (Platform.OS === 'web') {
      return AsyncStorage.getItem(key);
    }
    return SecureStore.getItemAsync(key);
  },

  async removeItem(key: string): Promise<void> {
    if (Platform.OS === 'web') {
      await AsyncStorage.removeItem(key);
    } else {
      await SecureStore.deleteItemAsync(key);
    }
  },
};

export const tokenStorage = {
  async saveTokens(tokens: AuthTokens): Promise<void> {
    await secureStorage.setItem(TOKENS_KEY, JSON.stringify(tokens));
  },

  async getTokens(): Promise<AuthTokens | null> {
    const data = await secureStorage.getItem(TOKENS_KEY);
    return data ? JSON.parse(data) : null;
  },

  async clearTokens(): Promise<void> {
    await secureStorage.removeItem(TOKENS_KEY);
  },
};

export const userStorage = {
  async saveUser(user: User): Promise<void> {
    await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
  },

  async getUser(): Promise<User | null> {
    const data = await AsyncStorage.getItem(USER_KEY);
    return data ? JSON.parse(data) : null;
  },

  async clearUser(): Promise<void> {
    await AsyncStorage.removeItem(USER_KEY);
  },
};

export const clearAllAuthData = async (): Promise<void> => {
  await Promise.all([
    tokenStorage.clearTokens(),
    userStorage.clearUser(),
  ]);
};

API Service with Interceptors

Create an Axios instance with automatic token refresh:

// src/utils/api.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { tokenStorage } from './storage';

const API_URL = 'https://api.yourapp.com';

export const api = axios.create({
  baseURL: API_URL,
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  },
});

let isRefreshing = false;
let failedQueue: Array<{
  resolve: (token: string) => void;
  reject: (error: Error) => void;
}> = [];

const processQueue = (error: Error | null, token: string | null) => {
  failedQueue.forEach((promise) => {
    if (error) {
      promise.reject(error);
    } else {
      promise.resolve(token!);
    }
  });
  failedQueue = [];
};

// Request interceptor - attach access token
api.interceptors.request.use(
  async (config: InternalAxiosRequestConfig) => {
    const tokens = await tokenStorage.getTokens();
    if (tokens?.accessToken) {
      config.headers.Authorization = `Bearer ${tokens.accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor - handle token refresh
api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };

    // If 401 and not already retrying
    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Queue the request while refresh is in progress
        return new Promise((resolve, reject) => {
          failedQueue.push({
            resolve: (token: string) => {
              originalRequest.headers.Authorization = `Bearer ${token}`;
              resolve(api(originalRequest));
            },
            reject: (err: Error) => reject(err),
          });
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const tokens = await tokenStorage.getTokens();
        if (!tokens?.refreshToken) {
          throw new Error('No refresh token');
        }

        const response = await axios.post(`${API_URL}/auth/refresh`, {
          refreshToken: tokens.refreshToken,
        });

        const newTokens = {
          accessToken: response.data.accessToken,
          refreshToken: response.data.refreshToken,
          expiresAt: Date.now() + response.data.expiresIn * 1000,
        };

        await tokenStorage.saveTokens(newTokens);
        processQueue(null, newTokens.accessToken);

        originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError as Error, null);
        await tokenStorage.clearTokens();
        // Navigate to login - handled by auth context
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

Authentication Service

Create API methods for authentication:

// src/auth/authService.ts
import { api } from '../utils/api';
import {
  AuthResponse,
  LoginRequest,
  RegisterRequest,
  User,
} from './authTypes';

export const authService = {
  async login(credentials: LoginRequest): Promise<AuthResponse> {
    const response = await api.post<AuthResponse>('/auth/login', credentials);
    return response.data;
  },

  async register(data: RegisterRequest): Promise<AuthResponse> {
    const response = await api.post<AuthResponse>('/auth/register', data);
    return response.data;
  },

  async logout(refreshToken: string): Promise<void> {
    await api.post('/auth/logout', { refreshToken });
  },

  async getCurrentUser(): Promise<User> {
    const response = await api.get<User>('/auth/me');
    return response.data;
  },

  async updateProfile(updates: Partial<User>): Promise<User> {
    const response = await api.patch<User>('/auth/profile', updates);
    return response.data;
  },

  async refreshTokens(refreshToken: string): Promise<AuthResponse> {
    const response = await api.post<AuthResponse>('/auth/refresh', {
      refreshToken,
    });
    return response.data;
  },

  async forgotPassword(email: string): Promise<void> {
    await api.post('/auth/forgot-password', { email });
  },

  async resetPassword(token: string, password: string): Promise<void> {
    await api.post('/auth/reset-password', { token, password });
  },
};

Auth Reducer

Implement the reducer for predictable state updates:

// src/auth/authReducer.ts
import { AuthState, AuthAction } from './authTypes';

export const initialState: AuthState = {
  isAuthenticated: false,
  isLoading: true, // Start with loading to check stored tokens
  user: null,
  tokens: null,
  error: null,
};

export function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'AUTH_START':
      return {
        ...state,
        isLoading: true,
        error: null,
      };

    case 'AUTH_SUCCESS':
      return {
        ...state,
        isAuthenticated: true,
        isLoading: false,
        user: action.payload.user,
        tokens: action.payload.tokens,
        error: null,
      };

    case 'AUTH_FAILURE':
      return {
        ...state,
        isAuthenticated: false,
        isLoading: false,
        user: null,
        tokens: null,
        error: action.payload,
      };

    case 'LOGOUT':
      return {
        ...initialState,
        isLoading: false,
      };

    case 'RESTORE_TOKEN':
      return {
        ...state,
        isAuthenticated: true,
        isLoading: false,
        user: action.payload.user,
        tokens: action.payload.tokens,
      };

    case 'UPDATE_USER':
      return {
        ...state,
        user: action.payload,
      };

    case 'REFRESH_TOKENS':
      return {
        ...state,
        tokens: action.payload,
      };

    default:
      return state;
  }
}

Auth Context Provider

Implement the main context provider with all authentication logic:

// src/auth/AuthContext.tsx
import React, {
  createContext,
  useContext,
  useReducer,
  useEffect,
  useCallback,
  useMemo,
} from 'react';
import { authReducer, initialState } from './authReducer';
import { authService } from './authService';
import { tokenStorage, userStorage, clearAllAuthData } from '../utils/storage';
import { AuthContextType, AuthTokens } from './authTypes';

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

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const [state, dispatch] = useReducer(authReducer, initialState);

  // Restore authentication state on app start
  useEffect(() => {
    const restoreAuthState = async () => {
      try {
        const [tokens, user] = await Promise.all([
          tokenStorage.getTokens(),
          userStorage.getUser(),
        ]);

        if (tokens && user) {
          // Check if token is expired
          if (tokens.expiresAt > Date.now()) {
            dispatch({ type: 'RESTORE_TOKEN', payload: { user, tokens } });
          } else {
            // Try to refresh
            try {
              const response = await authService.refreshTokens(tokens.refreshToken);
              const newTokens: AuthTokens = {
                accessToken: response.accessToken,
                refreshToken: response.refreshToken,
                expiresAt: Date.now() + 3600 * 1000, // 1 hour
              };
              await tokenStorage.saveTokens(newTokens);
              dispatch({ type: 'RESTORE_TOKEN', payload: { user: response.user, tokens: newTokens } });
            } catch {
              await clearAllAuthData();
              dispatch({ type: 'LOGOUT' });
            }
          }
        } else {
          dispatch({ type: 'LOGOUT' });
        }
      } catch (error) {
        console.error('Failed to restore auth state:', error);
        dispatch({ type: 'LOGOUT' });
      }
    };

    restoreAuthState();
  }, []);

  const login = useCallback(async (email: string, password: string) => {
    dispatch({ type: 'AUTH_START' });

    try {
      const response = await authService.login({ email, password });

      const tokens: AuthTokens = {
        accessToken: response.accessToken,
        refreshToken: response.refreshToken,
        expiresAt: Date.now() + 3600 * 1000,
      };

      await Promise.all([
        tokenStorage.saveTokens(tokens),
        userStorage.saveUser(response.user),
      ]);

      dispatch({
        type: 'AUTH_SUCCESS',
        payload: { user: response.user, tokens },
      });
    } catch (error: any) {
      const message = error.response?.data?.message || 'Login failed';
      dispatch({ type: 'AUTH_FAILURE', payload: message });
      throw new Error(message);
    }
  }, []);

  const register = useCallback(
    async (name: string, email: string, password: string) => {
      dispatch({ type: 'AUTH_START' });

      try {
        const response = await authService.register({ name, email, password });

        const tokens: AuthTokens = {
          accessToken: response.accessToken,
          refreshToken: response.refreshToken,
          expiresAt: Date.now() + 3600 * 1000,
        };

        await Promise.all([
          tokenStorage.saveTokens(tokens),
          userStorage.saveUser(response.user),
        ]);

        dispatch({
          type: 'AUTH_SUCCESS',
          payload: { user: response.user, tokens },
        });
      } catch (error: any) {
        const message = error.response?.data?.message || 'Registration failed';
        dispatch({ type: 'AUTH_FAILURE', payload: message });
        throw new Error(message);
      }
    },
    []
  );

  const logout = useCallback(async () => {
    try {
      if (state.tokens?.refreshToken) {
        await authService.logout(state.tokens.refreshToken);
      }
    } catch (error) {
      console.error('Logout API error:', error);
    } finally {
      await clearAllAuthData();
      dispatch({ type: 'LOGOUT' });
    }
  }, [state.tokens?.refreshToken]);

  const updateProfile = useCallback(async (updates: Partial<User>) => {
    try {
      const updatedUser = await authService.updateProfile(updates);
      await userStorage.saveUser(updatedUser);
      dispatch({ type: 'UPDATE_USER', payload: updatedUser });
    } catch (error: any) {
      throw new Error(error.response?.data?.message || 'Update failed');
    }
  }, []);

  const value = useMemo(
    () => ({
      ...state,
      login,
      register,
      logout,
      updateProfile,
    }),
    [state, login, register, logout, updateProfile]
  );

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

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

Login Screen

Create a complete login screen with validation and error handling:

// src/screens/LoginScreen.tsx
import React, { useState } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  StyleSheet,
  ActivityIndicator,
  KeyboardAvoidingView,
  Platform,
  Alert,
} from 'react-native';
import { useAuth } from '../auth/AuthContext';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';

type Props = {
  navigation: NativeStackNavigationProp<any>;
};

const LoginScreen: React.FC<Props> = ({ navigation }) => {
  const { login, isLoading, error } = useAuth();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [localError, setLocalError] = useState('');

  const validateForm = (): boolean => {
    if (!email.trim()) {
      setLocalError('Email is required');
      return false;
    }
    if (!/\S+@\S+\.\S+/.test(email)) {
      setLocalError('Please enter a valid email');
      return false;
    }
    if (!password) {
      setLocalError('Password is required');
      return false;
    }
    if (password.length < 6) {
      setLocalError('Password must be at least 6 characters');
      return false;
    }
    setLocalError('');
    return true;
  };

  const handleLogin = async () => {
    if (!validateForm()) return;

    try {
      await login(email.toLowerCase().trim(), password);
    } catch (err: any) {
      Alert.alert('Login Failed', err.message);
    }
  };

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
    >
      <View style={styles.form}>
        <Text style={styles.title}>Welcome Back</Text>
        <Text style={styles.subtitle}>Sign in to continue</Text>

        {(localError || error) && (
          <Text style={styles.error}>{localError || error}</Text>
        )}

        <TextInput
          style={styles.input}
          placeholder="Email"
          value={email}
          onChangeText={setEmail}
          keyboardType="email-address"
          autoCapitalize="none"
          autoCorrect={false}
          editable={!isLoading}
        />

        <TextInput
          style={styles.input}
          placeholder="Password"
          value={password}
          onChangeText={setPassword}
          secureTextEntry
          editable={!isLoading}
        />

        <TouchableOpacity
          style={[styles.button, isLoading && styles.buttonDisabled]}
          onPress={handleLogin}
          disabled={isLoading}
        >
          {isLoading ? (
            <ActivityIndicator color="#fff" />
          ) : (
            <Text style={styles.buttonText}>Sign In</Text>
          )}
        </TouchableOpacity>

        <TouchableOpacity
          style={styles.linkButton}
          onPress={() => navigation.navigate('ForgotPassword')}
        >
          <Text style={styles.linkText}>Forgot Password?</Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={styles.linkButton}
          onPress={() => navigation.navigate('Register')}
        >
          <Text style={styles.linkText}>
            Don't have an account? <Text style={styles.linkBold}>Sign Up</Text>
          </Text>
        </TouchableOpacity>
      </View>
    </KeyboardAvoidingView>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#f5f5f5' },
  form: { flex: 1, justifyContent: 'center', padding: 24 },
  title: { fontSize: 32, fontWeight: 'bold', color: '#333', marginBottom: 8 },
  subtitle: { fontSize: 16, color: '#666', marginBottom: 32 },
  input: {
    backgroundColor: '#fff',
    borderRadius: 8,
    padding: 16,
    marginBottom: 16,
    fontSize: 16,
    borderWidth: 1,
    borderColor: '#ddd',
  },
  button: {
    backgroundColor: '#007AFF',
    borderRadius: 8,
    padding: 16,
    alignItems: 'center',
    marginTop: 8,
  },
  buttonDisabled: { backgroundColor: '#ccc' },
  buttonText: { color: '#fff', fontSize: 18, fontWeight: '600' },
  error: { color: '#ff3b30', marginBottom: 16, textAlign: 'center' },
  linkButton: { marginTop: 16, alignItems: 'center' },
  linkText: { color: '#666', fontSize: 14 },
  linkBold: { color: '#007AFF', fontWeight: '600' },
});

export default LoginScreen;

Protected Navigation

Set up navigation that automatically switches based on auth state:

// src/navigation/AppNavigator.tsx
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../auth/AuthContext';
import LoadingScreen from '../components/LoadingScreen';

// Screens
import LoginScreen from '../screens/LoginScreen';
import RegisterScreen from '../screens/RegisterScreen';
import HomeScreen from '../screens/HomeScreen';
import ProfileScreen from '../screens/ProfileScreen';

const Stack = createNativeStackNavigator();

const AuthStack = () => (
  <Stack.Navigator screenOptions={{ headerShown: false }}>
    <Stack.Screen name="Login" component={LoginScreen} />
    <Stack.Screen name="Register" component={RegisterScreen} />
    <Stack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
  </Stack.Navigator>
);

const MainStack = () => (
  <Stack.Navigator>
    <Stack.Screen name="Home" component={HomeScreen} />
    <Stack.Screen name="Profile" component={ProfileScreen} />
  </Stack.Navigator>
);

const AppNavigator: React.FC = () => {
  const { isAuthenticated, isLoading } = useAuth();

  if (isLoading) {
    return <LoadingScreen />;
  }

  return (
    <NavigationContainer>
      {isAuthenticated ? <MainStack /> : <AuthStack />}
    </NavigationContainer>
  );
};

export default AppNavigator;

App Entry Point

// App.tsx
import React from 'react';
import { AuthProvider } from './src/auth/AuthContext';
import AppNavigator from './src/navigation/AppNavigator';

export default function App() {
  return (
    <AuthProvider>
      <AppNavigator />
    </AuthProvider>
  );
}

Common Mistakes to Avoid

Watch out for these common pitfalls when implementing authentication:

1. Storing Tokens in Plain AsyncStorage

// Wrong - tokens stored in plain text
await AsyncStorage.setItem('token', accessToken);

// Correct - use SecureStore or encrypted storage
await SecureStore.setItemAsync('token', accessToken);

2. Not Handling Token Expiration

// Wrong - assuming token is always valid
const token = await getToken();
api.defaults.headers.Authorization = `Bearer ${token}`;

// Correct - check expiration and refresh if needed
const tokens = await tokenStorage.getTokens();
if (tokens && tokens.expiresAt < Date.now()) {
  const newTokens = await refreshTokens(tokens.refreshToken);
  await tokenStorage.saveTokens(newTokens);
}

3. Missing useContext Error Boundary

// Wrong - returns undefined if used outside provider
export const useAuth = () => useContext(AuthContext);

// Correct - throw helpful error
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

4. Not Clearing Data on Logout

// Wrong - only updating state
const logout = () => dispatch({ type: 'LOGOUT' });

// Correct - clear storage and invalidate server session
const logout = async () => {
  await authService.logout(tokens.refreshToken); // Invalidate on server
  await clearAllAuthData(); // Clear local storage
  dispatch({ type: 'LOGOUT' });
};

Final Thoughts

The Context API provides everything you need for production-ready authentication in React Native. By combining it with secure storage, proper token management, and automatic refresh logic, you get a robust authentication system without external state management libraries. Start with this foundation and extend it with features like biometric authentication, social login, or multi-factor authentication as your app grows.

For more on mobile development patterns, read Flutter Login/Register Flow with Riverpod and Feature-First Folder Structure. For official React Native documentation, visit the React Native Security Guide and the React Context Documentation.

1 Comment

Leave a Comment