Implementing Protected Routes in React
A protected route is a page or section of your React app that only authenticated (and optionally authorized) users can access. Unauthorized users are redirected to the login page or a "403 Forbidden" page. Protected routes prevent users from viewing sensitive data (profiles, dashboards, admin panels) by checking authentication state and user roles before rendering. This article covers route guards with React Router v6, session persistence across page refreshes, and role-based access control (RBAC).
Authentication Check and Session Persistence
Checking Authentication on App Load
When the app loads, you must check if the user is already logged in (via a stored token or cookie). Otherwise, users see a login page even if they have an active session:
// useAuth.js (Custom hook)
import { useEffect, useState, useCallback } from 'react';
export function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Check if user is already logged in
checkAuthStatus();
}, []);
const checkAuthStatus = useCallback(async () => {
try {
// Call /api/me to verify the current session
const response = await fetch('/api/me', {
credentials: 'include' // Include httpOnly cookies
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
} else {
// Not authenticated
setUser(null);
}
} catch (err) {
console.error('Auth check failed:', err);
setUser(null);
setError(err.message);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(async () => {
try {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include'
});
setUser(null);
} catch (err) {
console.error('Logout failed:', err);
}
}, []);
return { user, loading, error, checkAuthStatus, logout };
}
App Component with Session Check
// App.js (React Router v6)
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './useAuth';
import LoginPage from './pages/LoginPage';
import Dashboard from './pages/Dashboard';
import AdminPanel from './pages/AdminPanel';
import ProtectedRoute from './components/ProtectedRoute';
function App() {
const { user, loading } = useAuth();
// Show a loading spinner while checking authentication
if (loading) {
return <div className="loading">Loading...</div>;
}
return (
<Router>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route
path="/dashboard"
element={<ProtectedRoute user={user} component={Dashboard} />}
/>
<Route
path="/admin"
element={
<ProtectedRoute
user={user}
component={AdminPanel}
requiredRole="admin"
/>
}
/>
{/* Redirect unknown routes to dashboard */}
<Route path="/" element={<Navigate to="/dashboard" />} />
<Route path="*" element={<Navigate to="/dashboard" />} />
</Routes>
</Router>
);
}
export default App;
ProtectedRoute Component
Basic Protected Route
// components/ProtectedRoute.js
import React from 'react';
import { Navigate } from 'react-router-dom';
/**
* ProtectedRoute: Guard a route with authentication and role checks.
*
* @param {Object} user - Current user object (null if not authenticated)
* @param {React.Component} component - Component to render if authorized
* @param {string} requiredRole - Optional role requirement (e.g., 'admin')
*/
function ProtectedRoute({ user, component: Component, requiredRole = null }) {
// User is not authenticated; redirect to login
if (!user) {
return <Navigate to="/login" replace />;
}
// Check role if required
if (requiredRole && user.role !== requiredRole) {
return (
<div className="forbidden">
<h1>403 - Access Denied</h1>
<p>You do not have permission to access this page.</p>
</div>
);
}
// User is authorized; render the component
return <Component user={user} />;
}
export default ProtectedRoute;
Advanced: Multi-Role Support
For apps with multiple roles (admin, editor, viewer), support role arrays:
function ProtectedRoute({ user, component: Component, requiredRoles = null }) {
if (!user) {
return <Navigate to="/login" replace />;
}
// Check if user has at least one of the required roles
if (requiredRoles) {
const hasRole = Array.isArray(requiredRoles)
? requiredRoles.includes(user.role)
: user.role === requiredRoles;
if (!hasRole) {
return (
<div className="forbidden">
<h1>403 - Access Denied</h1>
<p>You do not have permission to access this page.</p>
<p>Required roles: {Array.isArray(requiredRoles) ? requiredRoles.join(', ') : requiredRoles}</p>
</div>
);
}
}
return <Component user={user} />;
}
export default ProtectedRoute;
With Redirect After Login
For a smoother UX, redirect users to their intended page after login:
// LoginPage.js with redirect
import { useNavigate, useLocation } from 'react-router-dom';
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const handleLoginSuccess = (user) => {
// Redirect to the page they were trying to access, or default to /dashboard
const from = location.state?.from?.pathname || '/dashboard';
navigate(from, { replace: true });
};
return (
// ... login form
);
}
Then update ProtectedRoute to pass the redirect location:
function ProtectedRoute({ user, component: Component, requiredRole = null }) {
const location = useLocation();
if (!user) {
// Save the location they were trying to access
return <Navigate to="/login" state={{ from: location }} replace />;
}
// ... rest of checks
}
Backend Endpoint: /api/me
The backend must provide an endpoint that returns the current user's profile if they are authenticated:
// backend/routes/auth.js
const authMiddleware = require('../middleware/authMiddleware');
app.get('/api/me', authMiddleware, async (req, res) => {
// req.user is set by the middleware if the token is valid
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
// Return user profile
res.json({
user: {
id: req.user.id,
email: req.user.email,
name: req.user.name,
role: req.user.role,
avatar: req.user.avatar
}
});
});
// Auth middleware: extract and verify JWT from httpOnly cookie
function authMiddleware(req, res, next) {
const token = req.cookies.accessToken; // httpOnly cookie
if (!token) {
return res.status(401).json({ error: 'No token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
Dashboard Component Example
// pages/Dashboard.js
import React, { useEffect, useState } from 'react';
function Dashboard({ user }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch user-specific data
fetchDashboardData();
}, [user.id]);
const fetchDashboardData = async () => {
try {
const response = await fetch('/api/dashboard', {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to load dashboard data');
}
const data = await response.json();
setData(data);
} catch (err) {
console.error('Error:', err);
} finally {
setLoading(false);
}
};
if (loading) return <div>Loading...</div>;
return (
<div className="dashboard">
<h1>Welcome, {user.name || user.email}!</h1>
{/* Role-based UI: show admin button only to admins */}
{user.role === 'admin' && (
<a href="/admin" className="btn-admin">Admin Panel</a>
)}
{data && (
<div className="dashboard-content">
<h2>Your Data</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)}
</div>
);
}
export default Dashboard;
Advanced: Route-Level Permission Checking
For complex permission systems (not just roles), use a permission array:
// ProtectedRoute with permissions
function ProtectedRoute({ user, component: Component, requiredPermissions = null }) {
if (!user) {
return <Navigate to="/login" replace />;
}
if (requiredPermissions) {
const hasPermission = requiredPermissions.every(permission =>
user.permissions?.includes(permission)
);
if (!hasPermission) {
return (
<div className="forbidden">
<h1>403 - Access Denied</h1>
<p>You do not have the required permissions.</p>
</div>
);
}
}
return <Component user={user} />;
}
// Usage
<Route
path="/billing"
element={
<ProtectedRoute
user={user}
component={BillingPage}
requiredPermissions={['view:billing']}
/>
}
/>
Handling Token Expiration During Navigation
If a token expires while the user is navigating, catch the 401 and redirect to login:
// apiClient.js (with token refresh)
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true
});
apiClient.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
// Token expired or invalid; force login
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
Key Takeaways
- Always check authentication status on app load using an
/api/meendpoint - Use a ProtectedRoute component to guard pages and require authentication/authorization before rendering
- Pass the current user object to ProtectedRoute; if null, redirect to login
- Support role-based access control (RBAC) by checking
user.roleagainstrequiredRole - Save the user's intended destination before redirecting to login, then redirect back after authentication
- Provide meaningful error messages for 403 Forbidden (insufficient permissions)
- Use middleware on the backend to verify tokens and attach user info to requests
- Test protected routes by simulating token expiration and verifying redirects work
- For complex permissions, use a permission array instead of simple role strings
- Gracefully handle 401 errors by redirecting to login; catch these in HTTP client interceptors
Frequently Asked Questions
Should I check authentication on every component?
No. Check once on app load (in App.js or a Root layout). Then use ProtectedRoute to guard page-level access. Individual components can access the user via context or props without re-checking.
What if a token expires while the user is on a protected page?
The next API call will return 401. The HTTP client interceptor catches this and redirects to login. The user sees a brief error or is silently redirected depending on your UX design.
Can I conditionally render UI based on user role?
Yes. Use the user object in your component: {user.role === 'admin' && <AdminButton />}. This is different from route protection; it hides UI but does not prevent backend access (which is why routes must also be protected).
How do I test protected routes?
In tests, mock the useAuth hook to return different user objects (authenticated, unauthenticated, different roles). Use tools like React Testing Library and jest.mock to simulate these scenarios.
Should I redirect to login if the user has insufficient permissions?
Options: (1) redirect to login (assumes they can log in as a user with permissions), (2) show a 403 Forbidden page, or (3) hide the link from the UI entirely. Option 2 is most user-friendly; always protect the route (403) even if the user is authenticated.