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?
Real-world analogy
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
- The login flow — The user posts credentials, the .NET API returns a short-lived JWT (lesson 12), and the client stores it. Every later call sends it in the Authorization header.
- Where the token lives — Anything JavaScript can read, including localStorage, can be stolen by an XSS bug. A cookie marked httpOnly cannot be read by scripts. The browser sends it automatically, which opens the door to CSRF.
- The preferred trade-off — Keep the access token in memory and the refresh token in an httpOnly, Secure, SameSite cookie. A page reload loses the memory token, so the app asks the refresh endpoint for a new one. An XSS bug can still act as the user, so this narrows the risk but does not remove it.
- localStorage is a trade-off — It survives reloads and is simple, but any injected script can read it. For a low-risk app with strict content security rules it may be acceptable, while a high-risk app should avoid it.
- Attach the token in one place — Wrap fetch in the API client and add the header there. Components then do not touch the token.
- Handle 401 once — On a 401, call the refresh endpoint once and retry the original request. If refresh also fails, clear the session and send the user to login.
- ProtectedRoute redirects — A guard component checks the session and renders <Outlet />, or <Navigate to="/login" />. Save the current location in state so login can return the user there.
- Role-based UI is convenience — Hiding an Admin button improves the screen, but the user can still send the request by hand. The API must check the role or policy on every protected endpoint.
- Logging out everywhere — Clearing the client token only ends this browser. To end other sessions, the server must revoke refresh tokens, for example by changing a stored token version.
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. This is a fragment of an auth file. Session holds the token and the roles, and AuthState adds login and logout functions.
- 2. useAuth reads the context and throws a clear error if it is used outside the provider.
- 3. AuthProvider keeps the session in memory state, not in localStorage. useMemo keeps the value stable between renders.
- 4. ProtectedRoute redirects to login when there is no session, and saves the current location so login can send the user back.
- 5. The optional role check redirects to a forbidden page. It is only a convenience, because the server must enforce it too.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Using HTTP cookies (MDN)
- Role-based authorization in ASP.NET Core (Microsoft Learn)
- Free Full-Stack Playbook (React and TypeScript lessons) (Teamz Lab)
Lock it in