Protected Routes in React Router: Secure Authentication Patterns
Protected routes are essential for any application that requires user authentication. They prevent unauthorized access to sensitive pages and data while providing a seamless user experience. Yet implementing them correctly—handling edge cases like token expiration, race conditions, and permission hierarchies—is where most developers struggle.
This guide covers production-ready patterns for securing your React Router applications, from basic authentication checks to complex role-based access control systems.
Table of Contents
- Understanding Protected Routes
- Authentication Context Setup
- Basic Protected Route Component
- JWT Token Management
- Role-Based Access Control
- Loading States and Race Conditions
- Handling Token Expiration
- Practical Implementation Example
- Security Best Practices
- Common Pitfalls
- FAQ
Understanding Protected Routes
A protected route is a route that only renders if certain conditions are met—most commonly, if a user is authenticated and has the appropriate permissions. If the conditions aren't met, the application redirects to a login page or error page.
Protected routes solve several key problems:
- Preventing unauthorized access to sensitive pages
- Improving UX by showing the correct content to the right users
- Enforcing permissions across your application
- Centralizing access control logic
Common Protection Scenarios
Authentication-only routes: Only logged-in users can access. Used for dashboards, settings, user profiles.
Role-based routes: Only users with specific roles (admin, manager, user) can access. Used for admin panels, moderation tools.
Permission-based routes: Only users with specific permissions can access. Used for fine-grained access control.
Ownership-based routes: Users can only access resources they own. Used for edit pages, personal data.
Authentication Context Setup
The foundation for protected routes is a centralized authentication state. React's Context API provides a clean way to share authentication data across your entire application.
Creating an Authentication Context
import React, { createContext, useContext, useReducer, useCallback, useEffect } from 'react';
export interface User {
id: string;
name: string;
email: string;
roles: string[];
permissions: string[];
}
export interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
type AuthState = {
user: User | null;
isLoading: boolean;
error: string | null;
};
type AuthAction =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; payload: User }
| { type: 'LOGIN_ERROR'; payload: string }
| { type: 'LOGOUT' }
| { type: 'CHECK_AUTH_START' }
| { type: 'CHECK_AUTH_SUCCESS'; payload: User }
| { type: 'CHECK_AUTH_ERROR' };
const initialState: AuthState = {
user: null,
isLoading: true,
error: null,
};
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'LOGIN_START':
return { ...state, isLoading: true, error: null };
case 'LOGIN_SUCCESS':
return { ...state, isLoading: false, user: action.payload };
case 'LOGIN_ERROR':
return { ...state, isLoading: false, error: action.payload };
case 'LOGOUT':
return { ...state, user: null };
case 'CHECK_AUTH_START':
return { ...state, isLoading: true };
case 'CHECK_AUTH_SUCCESS':
return { ...state, isLoading: false, user: action.payload };
case 'CHECK_AUTH_ERROR':
return { ...state, isLoading: false };
default:
return state;
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);
// Check if user is already logged in on mount
useEffect(() => {
checkAuth();
}, []);
const login = useCallback(async (email: string, password: string) => {
dispatch({ type: 'LOGIN_START' });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const user: User = await response.json();
localStorage.setItem('authToken', user.id); // Simplified; use real JWT tokens
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({
type: 'LOGIN_ERROR',
payload: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}, []);
const logout = useCallback(async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
localStorage.removeItem('authToken');
dispatch({ type: 'LOGOUT' });
}
}, []);
const checkAuth = useCallback(async () => {
dispatch({ type: 'CHECK_AUTH_START' });
try {
const token = localStorage.getItem('authToken');
if (!token) {
dispatch({ type: 'CHECK_AUTH_ERROR' });
return;
}
const response = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error('Auth check failed');
}
const user: User = await response.json();
dispatch({ type: 'CHECK_AUTH_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'CHECK_AUTH_ERROR' });
}
}, []);
return (
<AuthContext.Provider
value={{
user: state.user,
isAuthenticated: state.user !== null,
isLoading: state.isLoading,
error: state.error,
login,
logout,
checkAuth,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
import React, { createContext, useContext, useReducer, useCallback, useEffect } from 'react';
const AuthContext = createContext(null);
const initialState = {
user: null,
isLoading: true,
error: null,
};
function authReducer(state, action) {
switch (action.type) {
case 'LOGIN_START':
return { ...state, isLoading: true, error: null };
case 'LOGIN_SUCCESS':
return { ...state, isLoading: false, user: action.payload };
case 'LOGIN_ERROR':
return { ...state, isLoading: false, error: action.payload };
case 'LOGOUT':
return { ...state, user: null };
case 'CHECK_AUTH_START':
return { ...state, isLoading: true };
case 'CHECK_AUTH_SUCCESS':
return { ...state, isLoading: false, user: action.payload };
case 'CHECK_AUTH_ERROR':
return { ...state, isLoading: false };
default:
return state;
}
}
export function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, initialState);
useEffect(() => {
checkAuth();
}, []);
const login = useCallback(async (email, password) => {
dispatch({ type: 'LOGIN_START' });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const user = await response.json();
localStorage.setItem('authToken', user.id);
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({
type: 'LOGIN_ERROR',
payload: error.message || 'Unknown error',
});
throw error;
}
}, []);
const logout = useCallback(async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
localStorage.removeItem('authToken');
dispatch({ type: 'LOGOUT' });
}
}, []);
const checkAuth = useCallback(async () => {
dispatch({ type: 'CHECK_AUTH_START' });
try {
const token = localStorage.getItem('authToken');
if (!token) {
dispatch({ type: 'CHECK_AUTH_ERROR' });
return;
}
const response = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error('Auth check failed');
}
const user = await response.json();
dispatch({ type: 'CHECK_AUTH_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'CHECK_AUTH_ERROR' });
}
}, []);
return (
<AuthContext.Provider
value={{
user: state.user,
isAuthenticated: state.user !== null,
isLoading: state.isLoading,
error: state.error,
login,
logout,
checkAuth,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Basic Protected Route Component
Now that you have authentication state, create a component that checks authentication and either renders the requested page or redirects to login.
Simple ProtectedRoute Component
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthProvider';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <div className="flex items-center justify-center h-screen">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthProvider';
export function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return <div className="flex items-center justify-center h-screen">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
Using ProtectedRoute in Router Configuration
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AuthProvider } from './AuthProvider';
import { ProtectedRoute } from './ProtectedRoute';
import { LoginPage } from './pages/LoginPage';
import { Dashboard } from './pages/Dashboard';
import { Settings } from './pages/Settings';
const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{
path: '/dashboard',
element: (
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
),
},
{
path: '/settings',
element: (
<ProtectedRoute>
<Settings />
</ProtectedRoute>
),
},
]);
export function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AuthProvider } from './AuthProvider';
import { ProtectedRoute } from './ProtectedRoute';
import { LoginPage } from './pages/LoginPage';
import { Dashboard } from './pages/Dashboard';
import { Settings } from './pages/Settings';
const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{
path: '/dashboard',
element: (
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
),
},
{
path: '/settings',
element: (
<ProtectedRoute>
<Settings />
</ProtectedRoute>
),
},
]);
export function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
JWT Token Management
JWT (JSON Web Tokens) are the industry standard for authentication. They're self-contained credentials that include user information and can be verified without querying the server again.
Storing and Using JWT Tokens
interface TokenPayload {
userId: string;
email: string;
roles: string[];
exp: number; // Expiration timestamp
}
function decodeJWT(token: string): TokenPayload {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid token format');
}
const decoded = JSON.parse(
atob(parts[1]) // Decode the payload (second part)
);
return decoded;
}
function isTokenExpired(token: string): boolean {
try {
const payload = decodeJWT(token);
return Date.now() >= payload.exp * 1000;
} catch {
return true;
}
}
// In your auth context login/checkAuth
const login = useCallback(async (email: string, password: string) => {
dispatch({ type: 'LOGIN_START' });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const { token } = await response.json();
localStorage.setItem('token', token);
const payload = decodeJWT(token);
const user: User = {
id: payload.userId,
email: payload.email,
roles: payload.roles,
permissions: [], // Could be encoded in JWT
name: payload.email.split('@')[0],
};
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({
type: 'LOGIN_ERROR',
payload: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}, []);
function decodeJWT(token) {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid token format');
}
const decoded = JSON.parse(atob(parts[1]));
return decoded;
}
function isTokenExpired(token) {
try {
const payload = decodeJWT(token);
return Date.now() >= payload.exp * 1000;
} catch {
return true;
}
}
const login = useCallback(async (email, password) => {
dispatch({ type: 'LOGIN_START' });
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
const { token } = await response.json();
localStorage.setItem('token', token);
const payload = decodeJWT(token);
const user = {
id: payload.userId,
email: payload.email,
roles: payload.roles,
permissions: [],
name: payload.email.split('@')[0],
};
dispatch({ type: 'LOGIN_SUCCESS', payload: user });
} catch (error) {
dispatch({
type: 'LOGIN_ERROR',
payload: error.message || 'Unknown error',
});
throw error;
}
}, []);
Role-Based Access Control
Different users need different levels of access. Implement role-based access control (RBAC) by checking user roles before rendering protected content.
RoleProtectedRoute Component
interface RoleProtectedRouteProps {
children: React.ReactNode;
allowedRoles: string[];
}
export function RoleProtectedRoute({ children, allowedRoles }: RoleProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (!user || !user.roles.some(role => allowedRoles.includes(role))) {
return (
<div className="flex flex-col items-center justify-center h-screen">
<h1 className="text-2xl font-bold">Access Denied</h1>
<p className="text-gray-600">You don't have permission to access this page</p>
</div>
);
}
return children;
}
export function RoleProtectedRoute({ children, allowedRoles }) {
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (!user || !user.roles.some(role => allowedRoles.includes(role))) {
return (
<div className="flex flex-col items-center justify-center h-screen">
<h1 className="text-2xl font-bold">Access Denied</h1>
<p className="text-gray-600">You don't have permission to access this page</p>
</div>
);
}
return children;
}
Using RoleProtectedRoute
const router = createBrowserRouter([
{
path: '/admin',
element: (
<RoleProtectedRoute allowedRoles={['admin']}>
<AdminPanel />
</RoleProtectedRoute>
),
},
{
path: '/moderation',
element: (
<RoleProtectedRoute allowedRoles={['admin', 'moderator']}>
<ModerationDashboard />
</RoleProtectedRoute>
),
},
]);
const router = createBrowserRouter([
{
path: '/admin',
element: (
<RoleProtectedRoute allowedRoles={['admin']}>
<AdminPanel />
</RoleProtectedRoute>
),
},
{
path: '/moderation',
element: (
<RoleProtectedRoute allowedRoles={['admin', 'moderator']}>
<ModerationDashboard />
</RoleProtectedRoute>
),
},
]);
Loading States and Race Conditions
A common issue: the auth state is being checked asynchronously, but routes render before the check completes. This causes a flash of redirect screens before the correct page loads.
Handling Loading States Correctly
export function App() {
const { isLoading } = useAuth();
// Show a loading screen while authentication is being verified
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Verifying your credentials...</p>
</div>
</div>
);
}
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
export function App() {
const { isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Verifying your credentials...</p>
</div>
</div>
);
}
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
Preventing Flash of Redirect
The issue is that AuthProvider initializes with isLoading: true, but RouterProvider starts rendering routes immediately. Restructure your app to only render routes after auth check completes:
export function AppContent() {
return <RouterProvider router={router} />;
}
export function App() {
const { isLoading } = useAuth();
if (isLoading) {
return <LoadingScreen />;
}
return <AppContent />;
}
export default function Root() {
return (
<AuthProvider>
<App />
</AuthProvider>
);
}
export function AppContent() {
return <RouterProvider router={router} />;
}
export function App() {
const { isLoading } = useAuth();
if (isLoading) {
return <LoadingScreen />;
}
return <AppContent />;
}
export default function Root() {
return (
<AuthProvider>
<App />
</AuthProvider>
);
}
Handling Token Expiration
Tokens expire for security reasons. When a token expires, the user needs to re-authenticate, but ideally without losing their work.
Implementing Automatic Token Refresh
interface AuthContextType {
// ... existing properties
refreshToken: () => Promise<void>;
}
const authReducer = (state: AuthState, action: AuthAction): AuthState => {
// ... existing cases
switch (action.type) {
case 'TOKEN_REFRESH_START':
return { ...state, isLoading: true };
case 'TOKEN_REFRESH_SUCCESS':
return { ...state, isLoading: false };
case 'TOKEN_REFRESH_ERROR':
return { ...state, user: null };
}
};
const refreshToken = useCallback(async () => {
dispatch({ type: 'TOKEN_REFRESH_START' });
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: localStorage.getItem('refreshToken'),
}),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const { token } = await response.json();
localStorage.setItem('token', token);
dispatch({ type: 'TOKEN_REFRESH_SUCCESS' });
} catch (error) {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
dispatch({ type: 'TOKEN_REFRESH_ERROR' });
throw error;
}
}, []);
// Check token expiration periodically
useEffect(() => {
const interval = setInterval(() => {
const token = localStorage.getItem('token');
if (token && isTokenExpired(token)) {
refreshToken().catch(() => {
// Token refresh failed, user needs to login again
});
}
}, 60000); // Check every minute
return () => clearInterval(interval);
}, [refreshToken]);
const refreshToken = useCallback(async () => {
dispatch({ type: 'TOKEN_REFRESH_START' });
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
refreshToken: localStorage.getItem('refreshToken'),
}),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const { token } = await response.json();
localStorage.setItem('token', token);
dispatch({ type: 'TOKEN_REFRESH_SUCCESS' });
} catch (error) {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
dispatch({ type: 'TOKEN_REFRESH_ERROR' });
throw error;
}
}, []);
useEffect(() => {
const interval = setInterval(() => {
const token = localStorage.getItem('token');
if (token && isTokenExpired(token)) {
refreshToken().catch(() => {
// Token refresh failed
});
}
}, 60000);
return () => clearInterval(interval);
}, [refreshToken]);
Practical Implementation Example
Here's a complete, production-ready example combining all concepts:
Login Page
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from './AuthProvider';
export function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login, isLoading } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={handleSubmit} className="w-full max-w-md bg-white p-8 rounded shadow">
<h1 className="text-2xl font-bold mb-6">Login</h1>
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="mb-6">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
);
}
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from './AuthProvider';
export function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login, isLoading } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate('/dashboard');
} catch (err) {
setError(err.message || 'Login failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form onSubmit={handleSubmit} className="w-full max-w-md bg-white p-8 rounded shadow">
<h1 className="text-2xl font-bold mb-6">Login</h1>
{error && (
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="mb-6">
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
);
}
Protected Dashboard
import { useAuth } from './AuthProvider';
import { useNavigate } from 'react-router-dom';
export function Dashboard() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 py-6 flex justify-between items-center">
<h1 className="text-3xl font-bold">Dashboard</h1>
<div className="flex items-center space-x-4">
<span className="text-gray-600">Welcome, {user?.name}</span>
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Logout
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white p-6 rounded shadow">
<h2 className="text-xl font-bold mb-4">User Information</h2>
<p><strong>Email:</strong> {user?.email}</p>
<p><strong>Roles:</strong> {user?.roles.join(', ')}</p>
</div>
</main>
</div>
);
}
import { useAuth } from './AuthProvider';
import { useNavigate } from 'react-router-dom';
export function Dashboard() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 py-6 flex justify-between items-center">
<h1 className="text-3xl font-bold">Dashboard</h1>
<div className="flex items-center space-x-4">
<span className="text-gray-600">Welcome, {user?.name}</span>
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Logout
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white p-6 rounded shadow">
<h2 className="text-xl font-bold mb-4">User Information</h2>
<p><strong>Email:</strong> {user?.email}</p>
<p><strong>Roles:</strong> {user?.roles.join(', ')}</p>
</div>
</main>
</div>
);
}
Security Best Practices
1. Never Store Sensitive Data in localStorage
// ❌ WRONG - localStorage is vulnerable to XSS
localStorage.setItem('password', password);
localStorage.setItem('apiKey', apiKey);
// ✅ CORRECT - only store tokens, and consider HttpOnly cookies
localStorage.setItem('refreshToken', refreshToken);
// Access token in memory only
2. Use HttpOnly Cookies for Access Tokens
// Server should set HttpOnly cookie
// Set-Cookie: accessToken=...; HttpOnly; Secure; SameSite=Strict
// Client-side fetch automatically includes cookies
const response = await fetch('/api/protected', {
credentials: 'include', // Include cookies
});
3. Implement CSRF Protection
// Include CSRF token in requests
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
await fetch('/api/protected', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
},
});
4. Validate Permissions on Backend
// ❌ WRONG - relying on frontend validation only
if (user.roles.includes('admin')) {
// Show admin panel
}
// ✅ CORRECT - backend always validates
// Every API endpoint must verify user permissions
// Example: GET /api/admin/users should return 403 if not admin
Common Pitfalls
Pitfall 1: Checking Permissions Before Auth Completes
Problem: Routes render before checkAuth() finishes, causing flashing redirects.
Solution: Keep the entire router inside a loading state until auth check completes (shown in the loading states section).
Pitfall 2: Not Handling Network Errors in Login
Problem: Network errors aren't distinguished from auth failures.
// ❌ WRONG
try {
await login(email, password);
} catch (error) {
setError('Login failed'); // Could be network or auth error
}
// ✅ CORRECT
try {
await login(email, password);
} catch (error) {
if (error instanceof TypeError) {
setError('Network error. Please check your connection.');
} else {
setError('Invalid email or password');
}
}
Pitfall 3: Not Clearing Auth State on Logout
Problem: User data remains in state after logout, leaking information.
// ✅ Ensure logout clears all sensitive data
const logout = useCallback(async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
} finally {
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
dispatch({ type: 'LOGOUT' }); // Clears user state
}
}, []);
Pitfall 4: Exposing User Roles on Frontend
Problem: Users can manipulate role data in the browser.
// ❌ WRONG
if (user.roles.includes('admin')) {
showAdminTools();
}
// ✅ CORRECT - Server validates on every request
// Frontend can optimistically show UI based on roles,
// but server MUST validate before performing actions
FAQ
Q: Should I store the access token in localStorage or sessionStorage?
A: Neither is ideal. HttpOnly cookies are more secure because they can't be accessed by JavaScript. If you must use localStorage, store only the refresh token and keep the access token in memory.
Q: What should I do if the refresh token expires?
A: Redirect the user to the login page. The refresh token should have a much longer expiration than the access token (e.g., 7 days vs 15 minutes). When it expires, the user must login again.
Q: How do I handle protected routes in a server-rendered or Next.js app?
A: Use getServerSideProps or getStaticProps to verify authentication on the server. React Router patterns don't apply the same way in SSR because routing happens on the server.
Q: Should I use a custom hook or higher-order component for protection?
A: Both work, but render-prop and hook patterns are more modern. The examples use render props (<ProtectedRoute>), but you could also create useProtected() hooks for inline checks.
Q: How do I implement "remember me" functionality?
A: Store the refresh token in localStorage with an extended expiration. When the user returns, use the refresh token to obtain a new access token without requiring login.
Q: Can I nest protected routes?
A: Yes, but it's redundant. <ProtectedRoute><RoleProtectedRoute> works, but you could combine them into a single component with more flexible props.
Key Takeaways
Protected routes are fundamental to building secure applications, but they require careful handling of authentication state, token management, and edge cases.
Core principles:
- Initialize auth state on app startup before rendering routes
- Always validate permissions on the backend
- Use secure token storage (HttpOnly cookies when possible)
- Handle token expiration gracefully
- Clear auth state completely on logout
- Distinguish between network errors and auth errors
- Never trust frontend role checks alone
Master these patterns and you'll build applications that are both secure and user-friendly.
Share your auth patterns! What authentication approach has worked best for your React applications? Leave your insights in the comments.
Google AdSense Placeholder
CONTENT SLOT