Skip to content

Protected Routes Interview Questions & Answers

20 questions Updated 2026-06-24 Share:

React Router v6 protected routes interview questions — RequireAuth wrapper, Navigate redirect, role-based access, auth context, token storage, and redirect-after-login patterns.

Read the in-depth guideReact Router v6 Protected Routes — Complete Interview Guide(opens in new tab)
20 of 20

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.

Without replace, the redirect adds the protected URL to the browser history stack. After login the user presses Back, lands on the protected page URL in history, gets redirected again, and is stuck in a loop. Using replace overwrites the history entry so the Back button goes to wherever they came from before the protected URL.

function RequireAuth() {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) {
    return (
      <Navigate
        to="/login"
        state={{ from: location }}
        replace   // ← replaces instead of pushing — prevents back-button loop
      />
    );
  }
  return <Outlet />;
}

Rule of thumb: Always pass replace on auth redirects to avoid back-button loops — it's a one-word fix with a significant UX impact.

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?

<Navigate> is a declarative redirect that happens during render — ideal for guards that decide synchronously (user is or isn't logged in right now). useNavigate() returns an imperative navigate() function — ideal for redirecting after an async operation (login form submit, token refresh).

// Declarative — fires during render, perfect for guards
function RequireAuth() {
  const { user } = useAuth();
  if (!user) return <Navigate to="/login" replace />; // renders a redirect
  return <Outlet />;
}

// Imperative — fires after async logic, perfect for post-login redirect
function LoginPage() {
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from?.pathname ?? '/dashboard';

  async function handleSubmit(credentials) {
    await login(credentials);       // async: wait for server response
    navigate(from, { replace: true }); // then redirect
  }
}

Rule of thumb: Use <Navigate> in guards (render-time decision), use useNavigate in event handlers (post-async decision).

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 ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel