React Authentication Flow - Complete 2025 Guide
Building secure authentication flows in React requires careful state management, proper token handling, and robust error handling. This guide covers modern authentication patterns and best practices for 2025.
Authentication Flow Overview
The Complete Authentication Cycle
sequenceDiagram
participant User
participant React
participant FastAPI
participant Database
User->>React: Login credentials
React->>FastAPI: POST /auth/login
FastAPI->>Database: Verify credentials
Database-->>FastAPI: User data
FastAPI-->>React: JWT tokens
React->>React: Store tokens
React->>User: Redirect to dashboard
User->>React: Access protected route
React->>React: Check token validity
React->>FastAPI: API call with token
FastAPI->>FastAPI: Verify JWT
FastAPI-->>React: Protected data
React->>User: Display content
1. Authentication Context Setup
Core Authentication Context
// contexts/AuthContext.tsx
import React, { createContext, useContext, useReducer, useEffect } from 'react';
import { AuthService } from '../services/AuthService';
import { User, LoginCredentials, SignupCredentials } from '../types/auth';
interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
error: string | null;
}
interface AuthContextType extends AuthState {
login: (credentials: LoginCredentials) => Promise<void>;
signup: (credentials: SignupCredentials) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
type AuthAction =
| { type: 'AUTH_START' }
| { type: 'AUTH_SUCCESS'; payload: User }
| { type: 'AUTH_ERROR'; payload: string }
| { type: 'AUTH_LOGOUT' }
| { type: 'CLEAR_ERROR' };
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
switch (action.type) {
case 'AUTH_START':
return { ...state, isLoading: true, error: null };
case 'AUTH_SUCCESS':
return {
...state,
user: action.payload,
isAuthenticated: true,
isLoading: false,
error: null,
};
case 'AUTH_ERROR':
return {
...state,
user: null,
isAuthenticated: false,
isLoading: false,
error: action.payload,
};
case 'AUTH_LOGOUT':
return {
...state,
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
};
case 'CLEAR_ERROR':
return { ...state, error: null };
default:
return state;
}
};
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, {
user: null,
isLoading: true,
isAuthenticated: false,
error: null,
});
// Initialize auth state on app load
useEffect(() => {
initializeAuth();
}, []);
const initializeAuth = async () => {
try {
const token = localStorage.getItem('accessToken');
if (!token) {
dispatch({ type: 'AUTH_LOGOUT' });
return;
}
// Verify token and get user data
const user = await AuthService.getCurrentUser();
dispatch({ type: 'AUTH_SUCCESS', payload: user });
} catch (error) {
console.error('Auth initialization failed:', error);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
dispatch({ type: 'AUTH_LOGOUT' });
}
};
const login = async (credentials: LoginCredentials) => {
dispatch({ type: 'AUTH_START' });
try {
const { user, accessToken, refreshToken } = await AuthService.login(credentials);
// Store tokens
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
dispatch({ type: 'AUTH_SUCCESS', payload: user });
} catch (error) {
const message = error instanceof Error ? error.message : 'Login failed';
dispatch({ type: 'AUTH_ERROR', payload: message });
throw error;
}
};
const signup = async (credentials: SignupCredentials) => {
dispatch({ type: 'AUTH_START' });
try {
const { user, accessToken, refreshToken } = await AuthService.signup(credentials);
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
dispatch({ type: 'AUTH_SUCCESS', payload: user });
} catch (error) {
const message = error instanceof Error ? error.message : 'Signup failed';
dispatch({ type: 'AUTH_ERROR', payload: message });
throw error;
}
};
const logout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
dispatch({ type: 'AUTH_LOGOUT' });
};
const refreshToken = async () => {
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const { accessToken: newAccessToken, user } = await AuthService.refreshToken(refreshToken);
localStorage.setItem('accessToken', newAccessToken);
if (user) {
dispatch({ type: 'AUTH_SUCCESS', payload: user });
}
} catch (error) {
console.error('Token refresh failed:', error);
logout();
throw error;
}
};
const clearError = () => {
dispatch({ type: 'CLEAR_ERROR' });
};
return (
<AuthContext.Provider value={{
...state,
login,
signup,
logout,
refreshToken,
clearError,
}}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
2. Authentication Service
API Integration Service
// services/AuthService.ts
import { ApiClient } from './ApiClient';
import { User, LoginCredentials, SignupCredentials, AuthResponse } from '../types/auth';
export class AuthService {
static async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await ApiClient.post('/auth/login', credentials);
return response.data;
}
static async signup(credentials: SignupCredentials): Promise<AuthResponse> {
const response = await ApiClient.post('/auth/signup', credentials);
return response.data;
}
static async getCurrentUser(): Promise<User> {
const response = await ApiClient.get('/auth/me');
return response.data;
}
static async refreshToken(refreshToken: string): Promise<{ accessToken: string; user?: User }> {
const response = await ApiClient.post('/auth/refresh', { refreshToken });
return response.data;
}
static async forgotPassword(email: string): Promise<void> {
await ApiClient.post('/auth/forgot-password', { email });
}
static async resetPassword(token: string, newPassword: string): Promise<void> {
await ApiClient.post('/auth/reset-password', { token, newPassword });
}
static async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await ApiClient.post('/auth/change-password', { currentPassword, newPassword });
}
static async verifyEmail(token: string): Promise<void> {
await ApiClient.post('/auth/verify-email', { token });
}
}
API Client with Interceptors
// services/ApiClient.ts
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
class ApiClient {
private client: AxiosInstance;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (value: any) => void;
reject: (error: any) => void;
}> = [];
constructor() {
this.client = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000',
timeout: 10000,
});
this.setupInterceptors();
}
private setupInterceptors() {
// Request interceptor - Add auth token
this.client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - Handle token refresh
this.client.interceptors.response.use(
(response: AxiosResponse) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) {
// Queue the request while token is being refreshed
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
}).then(() => {
return this.client(originalRequest);
}).catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
this.isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token');
}
const response = await this.client.post('/auth/refresh', { refreshToken });
const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);
// Process failed queue
this.failedQueue.forEach(({ resolve }) => {
resolve(accessToken);
});
this.failedQueue = [];
return this.client(originalRequest);
} catch (refreshError) {
// Refresh failed - logout user
this.failedQueue.forEach(({ reject }) => {
reject(refreshError);
});
this.failedQueue = [];
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
this.isRefreshing = false;
}
}
return Promise.reject(error);
}
);
}
get = this.client.get;
post = this.client.post;
put = this.client.put;
delete = this.client.delete;
patch = this.client.patch;
}
export default new ApiClient();
3. Protected Routes Component
Route Protection Implementation
// components/ProtectedRoute.tsx
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Loader } from './ui/Loader';
interface ProtectedRouteProps {
children: React.ReactNode;
requireAuth?: boolean;
redirectTo?: string;
roles?: string[];
}
export function ProtectedRoute({
children,
requireAuth = true,
redirectTo = '/login',
roles = []
}: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuth();
const location = useLocation();
if (isLoading) {
return <Loader />;
}
// Require authentication but user is not authenticated
if (requireAuth && !isAuthenticated) {
return <Navigate to={redirectTo} state={{ from: location }} replace />;
}
// User is authenticated but shouldn't be (e.g., login page)
if (!requireAuth && isAuthenticated) {
const from = location.state?.from?.pathname || '/dashboard';
return <Navigate to={from} replace />;
}
// Check role-based access
if (requireAuth && roles.length > 0 && user) {
const hasRequiredRole = roles.some(role => user.roles.includes(role));
if (!hasRequiredRole) {
return <Navigate to="/unauthorized" replace />;
}
}
return <>{children}</>;
}
Route Configuration
// App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import { LoginPage } from './pages/auth/LoginPage';
import { SignupPage } from './pages/auth/SignupPage';
import { DashboardPage } from './pages/DashboardPage';
import { AdminPage } from './pages/AdminPage';
function App() {
return (
<AuthProvider>
<Router>
<Routes>
{/* Public routes */}
<Route path="/" element={<HomePage />} />
{/* Auth routes - redirect if already authenticated */}
<Route
path="/login"
element={
<ProtectedRoute requireAuth={false}>
<LoginPage />
</ProtectedRoute>
}
/>
<Route
path="/signup"
element={
<ProtectedRoute requireAuth={false}>
<SignupPage />
</ProtectedRoute>
}
/>
{/* Protected routes */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
{/* Admin only routes */}
<Route
path="/admin"
element={
<ProtectedRoute roles={['admin']}>
<AdminPage />
</ProtectedRoute>
}
/>
{/* Error routes */}
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Router>
</AuthProvider>
);
}
export default App;
4. Login Component
Complete Login Form
// pages/auth/LoginPage.tsx
import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Button } from '../../components/ui/Button';
import { Input } from '../../components/ui/Input';
import { Alert } from '../../components/ui/Alert';
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { login, isLoading, error, clearError } = useAuth();
const [formData, setFormData] = useState({
email: '',
password: '',
rememberMe: false,
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const validateForm = () => {
const errors: Record<string, string> = {};
if (!formData.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
errors.email = 'Email is invalid';
}
if (!formData.password) {
errors.password = 'Password is required';
} else if (formData.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}
setValidationErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearError();
if (!validateForm()) {
return;
}
try {
await login({
email: formData.email,
password: formData.password,
});
// Redirect to intended page or dashboard
const from = location.state?.from?.pathname || '/dashboard';
navigate(from, { replace: true });
} catch (err) {
// Error is handled by AuthContext
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear validation error when user starts typing
if (validationErrors[name]) {
setValidationErrors(prev => ({
...prev,
[name]: '',
}));
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link
to="/signup"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
create a new account
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<Alert variant="error" onClose={clearError}>
{error}
</Alert>
)}
<div className="space-y-4">
<Input
label="Email address"
name="email"
type="email"
autoComplete="email"
required
value={formData.email}
onChange={handleChange}
error={validationErrors.email}
placeholder="Enter your email"
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="current-password"
required
value={formData.password}
onChange={handleChange}
error={validationErrors.password}
placeholder="Enter your password"
/>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="rememberMe"
name="rememberMe"
type="checkbox"
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
checked={formData.rememberMe}
onChange={handleChange}
/>
<label htmlFor="rememberMe" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div className="text-sm">
<Link
to="/forgot-password"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Forgot your password?
</Link>
</div>
</div>
</div>
<Button
type="submit"
variant="primary"
fullWidth
loading={isLoading}
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
</form>
</div>
</div>
);
}
5. Authentication Hooks
Custom Authentication Hooks
// hooks/useAuthRedirect.ts
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export function useAuthRedirect() {
const navigate = useNavigate();
const location = useLocation();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && isAuthenticated) {
const from = location.state?.from?.pathname || '/dashboard';
navigate(from, { replace: true });
}
}, [isAuthenticated, isLoading, navigate, location.state]);
}
// hooks/useRequireAuth.ts
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export function useRequireAuth(redirectTo = '/login') {
const navigate = useNavigate();
const { isAuthenticated, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !isAuthenticated) {
navigate(redirectTo, {
state: { from: location },
replace: true
});
}
}, [isAuthenticated, isLoading, navigate, redirectTo]);
return { isAuthenticated, isLoading };
}
// hooks/usePermissions.ts
import { useMemo } from 'react';
import { useAuth } from '../contexts/AuthContext';
export function usePermissions() {
const { user } = useAuth();
const permissions = useMemo(() => {
if (!user) return [];
return user.roles.flatMap(role => role.permissions || []);
}, [user]);
const hasPermission = (permission: string) => {
return permissions.includes(permission);
};
const hasRole = (role: string) => {
return user?.roles.some(r => r.name === role) || false;
};
const hasAnyRole = (roles: string[]) => {
return roles.some(role => hasRole(role));
};
return {
permissions,
hasPermission,
hasRole,
hasAnyRole,
};
}
6. Logout Implementation
Secure Logout with Cleanup
// components/LogoutButton.tsx
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Button } from './ui/Button';
interface LogoutButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
children?: React.ReactNode;
}
export function LogoutButton({ variant = 'secondary', children }: LogoutButtonProps) {
const { logout } = useAuth();
const handleLogout = async () => {
try {
// Optional: Call server logout endpoint
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`,
},
});
} catch (error) {
console.error('Server logout failed:', error);
} finally {
// Always perform client-side logout
logout();
}
};
return (
<Button variant={variant} onClick={handleLogout}>
{children || 'Logout'}
</Button>
);
}
7. TypeScript Types
Authentication Type Definitions
// types/auth.ts
export interface User {
id: string;
email: string;
name: string;
avatar?: string;
roles: Role[];
isVerified: boolean;
createdAt: string;
updatedAt: string;
}
export interface Role {
id: string;
name: string;
permissions: string[];
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface SignupCredentials {
name: string;
email: string;
password: string;
confirmPassword: string;
}
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface AuthError {
message: string;
code?: string;
field?: string;
}
Best Practices Summary
Security Best Practices
- Token Storage: Use httpOnly cookies for production
- Token Refresh: Implement automatic token refresh
- Route Protection: Always verify authentication on protected routes
- Error Handling: Provide clear error messages without exposing sensitive data
- Session Management: Handle multiple tabs and windows gracefully
Performance Optimizations
- Context Splitting: Separate auth context from other app state
- Lazy Loading: Load authentication components only when needed
- Memoization: Use React.memo for auth-dependent components
- Token Validation: Cache user data to avoid unnecessary API calls
UX Considerations
- Loading States: Show appropriate loading indicators
- Error Recovery: Provide retry mechanisms for failed requests
- Redirect Handling: Remember intended destinations after login
- Progressive Enhancement: Support users with disabled JavaScript