A protected route is a route that only renders its component when the user meets a certain condition — typically being authenticated. Without it, any visitor who knows a URL can directly navigate to sensitive pages (dashboards, admin panels, account settings) because the browser fetches the static JS bundle and renders the page entirely client-side.
The solution is an auth guard component that checks the auth state before rendering child routes. If the check fails it redirects to the login page instead of rendering the protected content.
// Minimal guard — renders children or redirects
function RequireAuth({ children }) {
const { user } = useAuth(); // check auth state
if (!user) {
return <Navigate to="/login" replace />; // kick to login
}
return children; // authenticated — render the page
}
Rule of thumb: A protected route is just a conditional render — show the page or redirect; the real security still lives on the server.
In React Router v6 the <Navigate> component performs a declarative redirect.
A RequireAuth wrapper checks auth state and either renders <Outlet /> (for
layout-route usage) or children (for direct wrapping).
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
// Layout-route variant — works with nested <Route> trees
export function RequireAuth() {
const { user } = useAuth();
const location = useLocation(); // capture current path
if (!user) {
// Pass current location so login can redirect back after success
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />; // render matched child route
}
In the router config, nest protected routes under RequireAuth:
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Route>
Rule of thumb: Use the layout-route (<Outlet />) pattern rather than
wrapping every <Route element> individually — one guard covers all children.
When unauthenticated users hit a protected URL you pass the current location in
state on the redirect. After a successful login you read location.state.from and
call navigate() to send them there instead of a hard-coded fallback.
// 1. Guard passes current location in redirect state
function RequireAuth() {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
// 2. Login page reads state.from after successful login
function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname ?? '/dashboard'; // fallback
async function handleSubmit(e) {
e.preventDefault();
await login(formData); // authenticate
navigate(from, { replace: true }); // go to original destination
}
// ...render form
}
Rule of thumb: Always supply a fallback path (?? '/dashboard') — if the
user bookmarks /login directly, state is undefined.
Extend the RequireAuth pattern with an allowedRoles prop. The guard checks both
authentication and authorization before rendering.
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
// allowedRoles: string[] — e.g. ['admin', 'editor']
function RequireRole({ allowedRoles }) {
const { user } = useAuth();
const location = useLocation();
if (!user) {
// Not logged in → redirect to login
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (!allowedRoles.includes(user.role)) {
// Logged in but wrong role → show 403
return <Navigate to="/403" replace />;
}
return <Outlet />;
}
// Router config
<Route element={<RequireRole allowedRoles={['admin']} />}>
<Route path="/admin" element={<AdminPanel />} />
</Route>
Rule of thumb: Always separate the two checks — unauthenticated users should see the login page, not a 403; authorized users with the wrong role should see 403, not the login page.
React Router v6's layout route pattern (a <Route> with no path but with an
element) lets you wrap a whole subtree. Combine this with RequireAuth to cover
all descendants at once.
<Routes>
{/* Public routes */}
<Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage />} />
{/* Protected subtree — single guard for all children */}
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
{/* Doubly-nested admin section with role check */}
<Route element={<RequireRole allowedRoles={['admin']} />}>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/admin/users" element={<UserManager />} />
</Route>
</Route>
</Routes>
Rule of thumb: Think of layout routes as middleware layers — stack them to compose auth + role checks without repeating logic on each leaf route.
Use React.lazy to code-split heavy protected pages so their JS chunk is only
downloaded after the guard confirms auth. Wrap the lazy import in <Suspense> inside
(or outside) the RequireAuth element.
import React, { Suspense } from 'react';
import { Route, Routes } from 'react-router-dom';
import { RequireAuth } from './guards/RequireAuth';
// Lazy imports — bundle splits here
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const AdminPanel = React.lazy(() => import('./pages/AdminPanel'));
function AppRoutes() {
return (
// Suspense can live here to cover all lazy routes at once
<Suspense fallback={<div>Loading…</div>}>
<Routes>
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<AdminPanel />} />
</Route>
</Routes>
</Suspense>
);
}
Rule of thumb: Place <Suspense> outside <Routes> to handle all lazy
routes uniformly; only drop it inside a specific <Route> if you need per-page
loading UI.
The storage choice affects how the auth guard reads the token on each render and what attack surface you expose.
| Storage | XSS risk | CSRF risk | Survives refresh | Notes |
|---|---|---|---|---|
localStorage |
High — JS-readable | None | Yes | Avoid for sensitive tokens |
httpOnly cookie |
None — server-set | Medium | Yes | Best for session tokens |
| Memory (React state) | Low | None | No | Requires silent-refresh |
// In-memory pattern — token lives only in AuthContext state
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null); // cleared on tab close
// On mount, attempt a silent refresh via httpOnly refresh-token cookie
useEffect(() => {
api.post('/auth/refresh')
.then(({ data }) => setUser(data.user)) // restore session
.catch(() => setUser(null)); // no valid cookie → stay logged out
}, []);
return <AuthContext.Provider value={{ user, setUser }}>{children}</AuthContext.Provider>;
}
Rule of thumb: Store the access token in memory, set the refresh token as
an httpOnly cookie — you get XSS-safety for the short-lived token and automatic
session persistence without localStorage.
On initial page load the auth context hasn't yet rehydrated (e.g., the silent-refresh call is in-flight). Two strategies exist:
Optimistic auth assumes the user is logged in until proven otherwise — renders the protected page immediately, then potentially redirects. Can cause a flash of protected content.
Loading-state guard holds rendering until auth is confirmed. Prevents flashing but shows a spinner on every cold load.
function RequireAuth() {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
// Wait for silent-refresh before deciding
return <div className="spinner" aria-label="Checking auth…" />;
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
Rule of thumb: Always use the loading-state guard — a brief spinner is a far better UX than flashing private data at unauthenticated users.
Set up an Axios/Fetch interceptor that detects a 401 response, silently requests a new access token using the refresh-token cookie, retries the original request, and only redirects to login when the refresh itself fails.
// api.js — Axios instance with interceptor
import axios from 'axios';
const api = axios.create({ baseURL: '/api', withCredentials: true });
let refreshPromise = null; // prevent concurrent refresh calls
api.interceptors.response.use(
res => res,
async error => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
// Deduplicate: reuse an in-flight refresh
refreshPromise = refreshPromise ?? api.post('/auth/refresh').finally(() => {
refreshPromise = null;
});
await refreshPromise; // wait for new token
return api(original); // retry original request
}
// Refresh failed → redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
);
Rule of thumb: Use a single shared refresh promise to prevent multiple parallel 401 responses from each triggering their own refresh race.
AuthContext stores auth state (user, loading, login, logout) in React context so
any component in the tree — including route guards — can read it without prop-drilling.
The useAuth hook wraps useContext and throws early if used outside the provider.
// context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Restore session on mount
api.get('/auth/me')
.then(({ data }) => setUser(data))
.catch(() => setUser(null))
.finally(() => setLoading(false)); // stop spinner
}, []);
const login = async (creds) => {
const { data } = await api.post('/auth/login', creds);
setUser(data.user); // update global state
};
const logout = async () => {
await api.post('/auth/logout');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// Enforces provider presence
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
}
Rule of thumb: Throw in useAuth when the context is null — it turns a silent
wrong-render into an obvious developer error.
Route-level guards (RequireAuth layout routes) handle coarse-grained access —
they prevent entire pages from rendering. Component-level guards handle
fine-grained UI elements within a page (e.g., an "Edit" button visible only to
admins).
// Route-level guard — whole page blocked for non-admins
<Route element={<RequireRole allowedRoles={['admin']} />}>
<Route path="/admin" element={<AdminDashboard />} />
</Route>
// Component-level guard — same page, conditional UI
function ArticlePage() {
const { user } = useAuth();
return (
<article>
<h1>{article.title}</h1>
<p>{article.body}</p>
{/* Only editors see the Edit button */}
{user?.role === 'editor' && (
<button onClick={handleEdit}>Edit</button>
)}
</article>
);
}
Rule of thumb: Use route-level guards for page access, component-level guards for UI elements — never rely on hiding UI elements as the sole security measure.
Render a custom wrapper that supplies a mock AuthContext value. This lets you test
both the authenticated and unauthenticated branches without a real auth server.
// test/utils.jsx
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { AuthContext } from '../context/AuthContext';
export function renderWithAuth(ui, { user = null, route = '/' } = {}) {
return render(
// MemoryRouter lets us set the initial URL
<MemoryRouter initialEntries={[route]}>
<AuthContext.Provider value={{ user, loading: false }}>
{ui}
</AuthContext.Provider>
</MemoryRouter>
);
}
// dashboard.test.jsx
import { screen } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom';
import { RequireAuth } from '../guards/RequireAuth';
import { Dashboard } from '../pages/Dashboard';
test('redirects to login when unauthenticated', () => {
renderWithAuth(
<Routes>
<Route path="/login" element={<p>Login page</p>} />
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} />
</Route>
</Routes>,
{ user: null, route: '/dashboard' } // no user
);
expect(screen.getByText('Login page')).toBeInTheDocument();
});
Rule of thumb: Use MemoryRouter (not BrowserRouter) in tests — you control
the initial URL without touching window.location.
Client-side protection (React Router guards) is UI-only — it prevents rendering the component but the API endpoints are still reachable by anyone with network tools. Server-side protection validates the token on every API call and is the true security boundary.
// Client-side guard — UX only, not a security boundary
function RequireAuth() {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
return <Outlet />;
}
// Server-side guard (Express example) — real security
function authMiddleware(req, res, next) {
const token = req.cookies.accessToken;
try {
req.user = jwt.verify(token, process.env.JWT_SECRET); // throws if invalid
next();
} catch {
res.status(401).json({ error: 'Unauthorized' }); // API rejects bad token
}
}
// Every protected API route uses the middleware
router.get('/api/dashboard-data', authMiddleware, getDashboardData);
Rule of thumb: Client-side guards protect UX; server-side guards protect data — you need both, and the server is the only one that matters for security.
The inverse of RequireAuth — a GuestOnly guard redirects authenticated users
who try to visit /login or /register to the dashboard instead of showing them
a form they don't need.
function GuestOnly() {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <div>Loading…</div>;
if (user) {
// Authenticated user tried to reach /login — send them home
const destination = location.state?.from?.pathname ?? '/dashboard';
return <Navigate to={destination} replace />;
}
return <Outlet />; // not logged in — show login/register
}
// Router config
<Route element={<GuestOnly />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Route>
Rule of thumb: Pair every RequireAuth guard with a GuestOnly guard — without
it, logged-in users see the login form and get confused.
On a hard refresh React state is wiped. To restore auth you have two options:
(1) read a token from localStorage synchronously on mount, or (2) fire a
silent-refresh API call on mount using an httpOnly refresh-token cookie.
Option 2 is more secure.
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); // true until we know
useEffect(() => {
// Silent refresh — relies on httpOnly cookie sent automatically
api.post('/auth/refresh')
.then(({ data }) => {
setUser(data.user); // restore user object
})
.catch(() => {
setUser(null); // no valid cookie → stay logged out
})
.finally(() => {
setLoading(false); // guards can now make a decision
});
}, []); // run once on mount
return (
<AuthContext.Provider value={{ user, loading }}>
{children}
</AuthContext.Provider>
);
}
Rule of thumb: Keep loading: true until the refresh attempt resolves — every
guard in the app blocks on this flag, preventing a premature redirect to /login.
Nest layout routes — each wraps the next, and all must pass before the leaf renders. This keeps each guard single-responsibility and independently testable.
// Each guard does one job and delegates via <Outlet />
function RequireSubscription() {
const { user } = useAuth();
if (!user?.subscription?.active) {
return <Navigate to="/upgrade" replace />;
}
return <Outlet />;
}
function RequireFeatureFlag({ flag }) {
const { flags } = useFeatureFlags();
if (!flags[flag]) {
return <Navigate to="/coming-soon" replace />;
}
return <Outlet />;
}
// Router — guards stack innermost-last
<Route element={<RequireAuth />}> {/* 1st: auth */}
<Route element={<RequireSubscription />}> {/* 2nd: paid plan */}
<Route element={<RequireFeatureFlag flag="ai-tools" />}> {/* 3rd: flag */}
<Route path="/ai-tools" element={<AiTools />} />
</Route>
</Route>
</Route>
Rule of thumb: Compose guards by nesting layout routes rather than combining logic in one mega-guard — each layer stays readable and individually unit-testable.
A redirect loop happens when the guard sends the user to /login, but /login is
also behind the guard (or the auth state never resolves to true), so the guard
redirects again immediately.
Common causes and fixes:
// BUG: /login is inside RequireAuth — redirects loop forever
<Route element={<RequireAuth />}>
<Route path="/login" element={<LoginPage />} /> {/* ← wrong! */}
<Route path="/dashboard" element={<Dashboard />} />
</Route>
// FIX: /login must be outside the guard
<Routes>
<Route path="/login" element={<LoginPage />} /> {/* public */}
<Route element={<RequireAuth />}>
<Route path="/dashboard" element={<Dashboard />} /> {/* protected */}
</Route>
</Routes>
// BUG 2: guard doesn't wait for loading — user is null on first render
// even when a valid refresh cookie exists → immediate redirect to login
function RequireAuth() {
const { user, loading } = useAuth();
if (loading) return null; // ← wait before deciding
if (!user) return <Navigate to="/login" replace />;
return <Outlet />;
}
Rule of thumb: If you see a redirect loop, check two things: is the redirect target outside the guard, and is the guard waiting for the loading flag?
A 404 means the URL doesn't match any route. A 403 means the URL matched but the user lacks permission. Conflating them leaks information (you reveal the route exists to unauthorized users). In practice, both are acceptable — many apps return a 404 for unauthorized routes to avoid enumeration.
function RequireRole({ allowedRoles }) {
const { user } = useAuth();
const location = useLocation();
if (!user) {
// Not logged in → login (don't reveal the page exists)
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (!allowedRoles.includes(user.role)) {
// Logged in, wrong role — choose 403 or 404 based on policy
return <Navigate to="/403" replace />; // explicit "forbidden" page
// OR: return <Navigate to="/404" replace />; // stealth — hides route existence
}
return <Outlet />;
}
// Catch-all 404 must be the last route in the tree
<Route path="*" element={<NotFoundPage />} />
Rule of thumb: Send unauthenticated users to login, send authenticated but unauthorized users to 403 (or 404 for sensitive admin routes) — keep the distinction deliberate and documented.
More Routing interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.