Lesson 35 of 45 intermediate

Authentication and Protected Routes

The client decides what to show, and only the server decides what is allowed

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

A user opens the browser tools and calls an admin endpoint by hand. What actually stops them?

Open interactive version (quiz + challenge)

Real-world analogy

At a concert, the usher at the door checks your wristband and sends you to the right area. The usher is helpful, but the usher is not the lock on the backstage door. If the backstage door has no lock, anyone who walks around the usher can go in. Wristbands also expire, so the usher sometimes sends you to the desk for a new one. The usher is the React route guard, the lock is server authorization, and the desk is the refresh flow.

What is it?

Authentication in a React app means getting proof of identity from the API. You keep it safely and attach it to calls. Protected routes are a user-experience layer: they send logged-out users to the login page. They do not secure anything by themselves, because all client code runs on the user's machine. Real access control is the server checking the token and the permission on each request.

Real-world relevance

A support dashboard has an Admin section. The team hides the menu link and guards the route in React, and ships. A tester opens the browser tools and calls the delete endpoint directly. It works, because the action had no authorization attribute. The fix is on the server. The client guard stays as a nicer experience, so users do not land on pages they cannot use.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

What is the main risk of keeping a JWT in localStorage?

What is the main extra risk of putting a token in a cookie?

Code example

import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
type Session = { token: string; roles: string[] };
type AuthState = { session: Session | null; login: (s: Session) => void; logout: () => void };
const AuthContext = createContext<AuthState | null>(null);
export function useAuth(): AuthState {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
  return ctx;
}
export function AuthProvider({ children }: { children: ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const value = useMemo(
    () => ({ session, login: setSession, logout: () => setSession(null) }),
    [session]
  );
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function ProtectedRoute({ role }: { role?: string }) {
  const { session } = useAuth();
  const location = useLocation();
  if (!session) return <Navigate to="/login" state={{ from: location }} replace />;
  if (role && !session.roles.includes(role)) return <Navigate to="/forbidden" replace />;
  return <Outlet />;
}

// fragment: LoginPage, OrdersPage and AdminPage are not shown
export const AppRoutes = () => (
  <Routes>
    <Route path="/login" element={<LoginPage />} />
    <Route element={<ProtectedRoute />}><Route path="/orders" element={<OrdersPage />} /></Route>
    <Route element={<ProtectedRoute role="Admin" />}><Route path="/admin" element={<AdminPage />} /></Route>
  </Routes>
);

Line-by-line walkthrough

  1. 1. This is a fragment of an auth file. Session holds the token and the roles, and AuthState adds login and logout functions.
  2. 2. useAuth reads the context and throws a clear error if it is used outside the provider.
  3. 3. AuthProvider keeps the session in memory state, not in localStorage. useMemo keeps the value stable between renders.
  4. 4. ProtectedRoute redirects to login when there is no session, and saves the current location so login can send the user back.
  5. 5. The optional role check redirects to a forbidden page. It is only a convenience, because the server must enforce it too.
  6. 6. AppRoutes nests pages inside the guard. The Outlet in ProtectedRoute renders the matching child page.

Spot the bug

// Client: only admins see the button
// apiFetch is the app's API client, which attaches the token
function OrdersAdmin({ roles }: { roles: string[] }) {
  if (!roles.includes('Admin')) return null;
  return <button onClick={() => apiFetch('/api/orders', { method: 'DELETE' })}>Delete all orders</button>;
}

// Server (C#): no [Authorize] on this action
// [HttpDelete("api/orders")]
// public async Task<IActionResult> DeleteAll() { await _orders.RemoveAllAsync(); return NoContent(); }
Need a hint?
Who is allowed to send a DELETE request to the API?
Show answer
The role check only hides the button. Anyone can send the DELETE request by hand, and the server accepts it because the action has no authorization. Fix: add [Authorize(Roles = "Admin")] or a policy on the action, and keep the hidden button as a convenience.

Explain like I'm 5

A club has a helper at the door who points you to the right room. But the helper is only there to be nice. The real locks are on the doors inside, and the club checks your wristband at each lock.

Fun fact

A typical JWT is signed, not encrypted. Anyone can decode its payload with a simple tool, so it should not hold secrets.

Hands-on challenge

Say it out loud in 30 seconds: "How do you handle login and protected pages in React with a JWT API?" Then check: did you mention where the token is stored and its trade-off, one place to attach it, refresh on 401, a ProtectedRoute, and the server enforcing roles?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook