Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Authentication & Protected Routes

Authentication in a React app is like a bouncer at a club: the server issues a wristband (a token) when you prove who you are, and on every subsequent visit to a VIP area (protected route), the bouncer checks whether you are wearing that wristband. If you are not, you get redirected to the door (the login page). In this chapter, we will learn how to consume a JWT authentication API, manage auth state globally, and protect routes so only authenticated users can access them.

How JWT Authentication Works

Before jumping into code, here is the full flow at a glance:
┌─────────────────────────────────────────────────────────────┐
│                  JWT Authentication Flow                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. User submits email + password                           │
│         │                                                   │
│         ▼                                                   │
│  2. Server validates credentials                            │
│         │                                                   │
│         ▼                                                   │
│  3. Server returns a signed JWT (JSON Web Token)            │
│         │                                                   │
│         ▼                                                   │
│  4. Client stores the token (localStorage / cookie)         │
│         │                                                   │
│         ▼                                                   │
│  5. Client sends token in headers for every API request     │
│         │                                                   │
│         ▼                                                   │
│  6. Server verifies token and returns protected data        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Storing the Token

When the user logs in, the server sends a JWT. We need to store this token so it persists across page refreshes.

Storage Options

StorageProsCons
localStoragePersists across tabs and refreshesVulnerable to XSS attacks
sessionStorageCleared when tab closesLost on new tabs
HttpOnly CookieNot accessible via JS (XSS-safe)Requires server-side setup, vulnerable to CSRF
Security tip: For production apps, HttpOnly cookies set by the server are the safest option because JavaScript cannot read them, which eliminates XSS token theft. For learning and prototypes, localStorage is fine.
const login = async (email, password) => {
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password })
  });
  
  const data = await res.json();
  
  if (data.token) {
    // Store the token so it survives page refreshes.
    // The token is a signed string like "eyJhbG..." that
    // the server will verify on every protected request.
    localStorage.setItem('token', data.token);
  }
};

Sending the Token

For every subsequent request to a protected route, we must include the token in the headers. Think of this like flashing your ID badge at every secure door in an office building.
const token = localStorage.getItem('token');

const res = await fetch('/api/protected', {
  headers: {
    // The "Bearer" prefix is the standard convention.
    // Some APIs use a custom header like 'x-auth-token' instead.
    'Authorization': `Bearer ${token}`
  }
});

Creating an Authenticated Fetch Helper

Rather than manually attaching the token to every request, wrap fetch in a reusable helper:
// api.js - a thin wrapper that auto-attaches the auth token
async function authFetch(url, options = {}) {
  const token = localStorage.getItem('token');

  const res = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      // Spread any custom headers the caller provides
      ...options.headers,
      // Attach the token if one exists
      ...(token ? { Authorization: `Bearer ${token}` } : {})
    }
  });

  // If the server responds with 401, the token is expired or invalid.
  // Clear it and redirect to login so the user can re-authenticate.
  if (res.status === 401) {
    localStorage.removeItem('token');
    window.location.href = '/login';
  }

  return res;
}

// Usage throughout your app
const users = await authFetch('/api/users').then(r => r.json());
Practical tip: Centralizing token handling in one place means you only need to update the logic once when requirements change (e.g., switching from localStorage to cookies, or adding token refresh).

Auth Context

We can manage the authentication state globally using Context API. This way every component in the tree — navigation bars, protected routes, profile pages — can check “is the user logged in?” without prop drilling. Think of the Auth Context as a building-wide PA system: when the user logs in or out, every room (component) that cares about identity hears the announcement instantly. AuthContext.js
import { createContext, useState, useEffect, useContext } from 'react';

const AuthContext = createContext(null);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);
  // Start as `true` because we need to check for a saved token
  // before we know if the user is authenticated or not.
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // On mount, check if a token already exists (e.g., the user
    // refreshed the page). If it does, validate it with the server.
    const token = localStorage.getItem('token');
    if (token) {
      // In production, you should verify the token with your
      // backend (e.g., GET /api/auth/me) to confirm it has not
      // expired or been revoked. For now we trust its presence.
      setUser({ token }); 
    }
    setLoading(false);
  }, []);

  const login = (token) => {
    localStorage.setItem('token', token);
    setUser({ token });
  };

  const logout = () => {
    localStorage.removeItem('token');
    setUser(null);
  };

  return (
    // Render children only after the auth check finishes.
    // This prevents a flash of the login page before the
    // saved token is read from localStorage.
    <AuthContext.Provider value={{ user, login, logout, loading }}>
      {!loading && children}
    </AuthContext.Provider>
  );
};

// Custom hook -- always prefer this over exporting the context directly.
// It gives you a single place to add error handling if the provider is missing.
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};
Common pitfall — stale auth state after token expiry: If a user stays on the page for hours, their JWT may expire server-side while user in state still looks valid. Protect against this by:
  1. Checking the HTTP response status in your fetch helper (401 means “token expired”).
  2. Adding a timer that checks token expiry client-side using the JWT exp claim.
  3. Implementing a refresh token flow for seamless re-authentication.

Protected Routes

We can create a wrapper component to protect routes that require authentication. This acts like a security checkpoint — if the user has a valid wristband (token), they pass through; otherwise they get redirected to the entrance (login page). PrivateRoute.jsx
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';

const PrivateRoute = () => {
  const { user, loading } = useAuth();
  const location = useLocation();

  // While checking auth state, show a loading indicator instead of
  // flashing the login page. This prevents a jarring UX on page refresh.
  if (loading) return <div>Loading...</div>;

  // If not authenticated, redirect to login.
  // Save the current location in `state` so we can redirect back
  // after the user successfully logs in.
  return user
    ? <Outlet />
    : <Navigate to="/login" state={{ from: location }} replace />;
};

export default PrivateRoute;

Using Private Routes

Wrap your protected routes with the PrivateRoute component. Any <Route> nested inside inherits the authentication check automatically. App.jsx
import { Routes, Route } from 'react-router-dom';
import PrivateRoute from './PrivateRoute';
import Dashboard from './Dashboard';
import Login from './Login';

function App() {
  return (
    <Routes>
      {/* Public routes -- anyone can access these */}
      <Route path="/login" element={<Login />} />
      
      {/* Protected routes -- PrivateRoute checks auth first */}
      <Route element={<PrivateRoute />}>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/profile" element={<Profile />} />
      </Route>
    </Routes>
  );
}

Redirecting Back After Login

When a user tries to access /dashboard without being logged in, we saved that location in the Navigate state above. Now the login page can redirect them back:
function LoginPage() {
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();

  // The route the user was trying to visit before being redirected.
  // Falls back to /dashboard if they navigated to /login directly.
  const from = location.state?.from?.pathname || '/dashboard';

  const handleSubmit = async (e) => {
    e.preventDefault();
    const token = await authenticateWithServer(email, password);
    login(token);
    // Send the user to where they originally wanted to go
    navigate(from, { replace: true });
  };

  return (/* login form */);
}

Role-Based Access Control

Many apps need more than just “logged in vs. not.” You may have admins, editors, and regular users who see different things. Analogy: If basic authentication is a bouncer checking IDs at the door, role-based access is the VIP section inside. You got past the door (you are authenticated), but the velvet rope (the role check) decides whether you can enter the VIP area.
function RoleRoute({ allowedRoles }) {
  const { user } = useAuth();

  // First check: is the user authenticated at all?
  if (!user) {
    return <Navigate to="/login" replace />;
  }

  // Second check: does the user's role match the allowed list?
  // This is an authorization check, not an authentication check.
  // The user is who they say they are -- but are they permitted?
  if (!allowedRoles.includes(user.role)) {
    return <Navigate to="/unauthorized" replace />;
  }

  return <Outlet />;
}

// Usage -- only admins and editors can access /admin
<Route element={<RoleRoute allowedRoles={['admin', 'editor']} />}>
  <Route path="/admin" element={<AdminPanel />} />
</Route>
Important: Role checks on the client are a UX convenience, not a security boundary. A determined user can modify JavaScript in the browser to bypass any client-side check. Always verify roles on the server for every API request that returns or modifies sensitive data.

Common Authentication Pitfalls

Pitfall 1 — Storing sensitive data in the JWT payload: JWTs are base64-encoded, not encrypted. Anyone can decode the payload. Never put passwords, credit card numbers, or secrets inside a JWT.Pitfall 2 — Not handling token expiry: A token that lives forever is a security risk. Always set an expiration (exp claim) on the server side and handle 401 responses gracefully on the client.Pitfall 3 — Forgetting the loading state: If your PrivateRoute renders before the auth check finishes (e.g., reading from localStorage in a useEffect), users will see a flash of the login page on every refresh. Always gate rendering on loading === false.

Summary

ConceptDescription
JWTA signed token the server issues after successful login
localStorageSimple client-side storage for tokens (use HttpOnly cookies in production)
Auth ContextGlobal state that makes user, login, and logout available everywhere
PrivateRouteWrapper component that redirects unauthenticated users to login
Redirect after loginSave the attempted URL in location.state and navigate back after auth
Role-based accessCheck user.role against allowed roles in a route guard
Token expiryHandle 401 responses and prompt re-authentication

Next Steps

In the next chapter, you’ll learn about Redux & State Management — managing complex global state with Redux Toolkit!

Interview Deep-Dive

Strong Answer: localStorage is vulnerable to Cross-Site Scripting (XSS). If an attacker injects JavaScript into your page (through a vulnerable dependency, user-generated content, or a DOM-based XSS flaw), that script can call localStorage.getItem('token') and exfiltrate the token to an attacker-controlled server. Once they have the token, they can impersonate the user from any device until the token expires. The attack surface is large because any JavaScript running on your origin has full localStorage access.HttpOnly cookies are invisible to JavaScript — document.cookie cannot read them, and no client-side API can access them. The cookie is automatically attached to HTTP requests by the browser. This eliminates XSS-based token theft entirely. However, HttpOnly cookies introduce Cross-Site Request Forgery (CSRF) risk: a malicious site can trigger a request to your API (via an image tag, form submission, or fetch from a different origin), and the browser automatically includes the cookie. You mitigate CSRF with SameSite cookie attributes (SameSite=Strict or SameSite=Lax), CSRF tokens, or checking the Origin/Referer header on the server.My recommendation for production: HttpOnly cookies with Secure, SameSite=Lax, and a short expiration (15-30 minutes) paired with a refresh token (also HttpOnly, longer expiration). The access token is verified on every request; the refresh token is used only at a dedicated /refresh endpoint to get a new access token. This way, even if a CSRF attack triggers a request, the access token expires quickly, limiting the damage window.For SPAs where the backend is on a different domain (common with microservices), cookies require careful CORS configuration (credentials: 'include', Access-Control-Allow-Credentials: true). Some teams use the Backend-for-Frontend (BFF) pattern: a thin server on the same origin as the SPA handles auth cookies and proxies API requests, keeping the token flow entirely server-side.Follow-up: A user reports that they are logged out after refreshing the page even though you store the token in localStorage. What could cause this?Several possible causes, in order of likelihood:First, the auth state initialization reads from localStorage but the component renders before the useEffect that loads the token completes. The PrivateRoute sees user === null, redirects to login, and by the time the effect runs, the user is already on the login page. Fix: initialize the loading state as true and do not render protected routes until loading is false.Second, the token in localStorage has expired. The server rejects it on the validation API call, and your auth logic clears the token and sets user to null. Fix: implement token refresh before the access token expires (proactive refresh using a timer based on the exp claim).Third, a bug in the logout flow clears localStorage as a side effect. For example, a cleanup function in a useEffect that runs on unmount and clears the token. Or a redirect to login that triggers the login page’s “clear stale tokens” logic.Fourth, browser privacy settings or extensions (like auto-clearing storage on tab close). Some browsers in private/incognito mode clear localStorage when the session ends.Debugging approach: add a console.log at the start of the auth initialization showing the localStorage value. If the token is there but the user state is null, the initialization logic has a bug. If the token is missing, something is clearing it — search the codebase for localStorage.removeItem('token') and localStorage.clear().
Strong Answer: The basic refresh flow: when an API response returns 401 (unauthorized), the client sends the refresh token to /api/auth/refresh, receives a new access token, retries the original request with the new token, and updates stored tokens. This should be transparent to the user — they should not see a login page unless the refresh token itself has expired.The tricky part is the concurrent 401 problem. If the user lands on a dashboard that fires 5 parallel API calls and the access token has just expired, all 5 return 401 simultaneously. Without coordination, you would fire 5 refresh requests, which is wasteful (and some servers invalidate the refresh token after one use, causing 4 of the 5 to fail).The solution is a refresh token lock. When the first 401 arrives, your fetch wrapper sets a “refreshing” flag and initiates the refresh. All subsequent 401 responses during the refresh check this flag and wait (using a Promise) for the refresh to complete. Once the new token arrives, all queued requests retry with the fresh token.Implementation: create a module-level variable let refreshPromise = null. When a 401 arrives and refreshPromise is null, start the refresh and store the Promise. If refreshPromise is not null, await it. When the refresh completes (success or failure), set refreshPromise back to null.If the refresh itself fails (refresh token expired or revoked), clear all auth state and redirect to login. At this point, the queued requests should all fail gracefully — do not retry them.Follow-up: How would you proactively refresh tokens before they expire instead of waiting for a 401?Decode the access token’s exp claim (JWTs are base64-encoded, so you can read the payload without a secret: JSON.parse(atob(token.split('.')[1]))). Calculate the time until expiry. Set a timer to refresh the token a few minutes before it expires — for a 15-minute token, refresh at 12 minutes.Use a useEffect in your AuthProvider that sets this timer when the token changes:
useEffect(() => {
  if (!token) return;
  const { exp } = parseJwt(token);
  const msUntilExpiry = exp * 1000 - Date.now();
  const refreshAt = msUntilExpiry - 2 * 60 * 1000; // 2 min before expiry
  const timer = setTimeout(() => refreshToken(), Math.max(refreshAt, 0));
  return () => clearTimeout(timer);
}, [token]);
This approach is better than reactive 401 handling because: the user never experiences a failed request followed by a retry (no latency spike), the refresh happens in the background, and the UX is seamless. Keep the reactive 401 handler as a fallback for edge cases like server clock drift or network delays that cause the proactive refresh to arrive late.
Strong Answer: The frontend is responsible for: securely storing tokens (HttpOnly cookies over localStorage), implementing HTTPS everywhere (never sending tokens over HTTP), sanitizing user input to prevent XSS (the primary vector for token theft), implementing proper CORS headers to prevent unauthorized origins from reading API responses, clearing sensitive data on logout (tokens, cached user data, in-memory state), and protecting routes client-side (redirecting unauthenticated users).What is NOT the frontend’s responsibility (but I see frontend developers trying to do it): token validation. Never verify JWT signatures in the browser. The client does not have the secret key, and even if it did, any validation logic in JavaScript can be bypassed by a malicious actor. The server must validate every token on every request. Client-side “validation” (checking the exp claim to show/hide UI) is UX logic, not security.Password hashing: never hash passwords in the browser before sending them. The HTTPS transport layer encrypts the password in transit. If you hash on the client, the hash becomes the de facto password — an attacker who steals the hash can authenticate without knowing the original password. Server-side hashing with bcrypt/argon2 is the only correct approach.Rate limiting and brute force protection: this must be server-side. A client-side “lock after 5 attempts” can be bypassed by directly calling the API. The server should implement account lockout, CAPTCHA triggers, and IP-based rate limiting.CSRF protection: while SameSite cookies handle most cases, the server must verify the Origin header or CSRF token on state-changing requests.The mental model: the frontend is a convenience layer for the user. All security enforcement happens on the server. If removing the frontend entirely and calling APIs directly with curl would bypass a security measure, that measure is implemented in the wrong place.Follow-up: How does Content Security Policy (CSP) help protect your React app, and what challenges does it introduce?CSP is an HTTP response header that tells the browser which sources of scripts, styles, images, and other resources are allowed. It is the most effective defense against XSS because even if an attacker injects a script tag, the browser refuses to execute it if the source is not in the CSP whitelist.For React apps, a strict CSP typically includes: script-src 'self' (only scripts from your origin), style-src 'self' 'unsafe-inline' (inline styles are common in React), and connect-src 'self' api.yourdomain.com (restrict fetch/XHR to known APIs).Challenges: React’s development mode uses eval() for error overlays, which requires script-src 'unsafe-eval' in development (never in production). CSS-in-JS libraries that inject <style> tags at runtime need style-src 'unsafe-inline' or nonce-based CSP. Dynamic script loading (analytics, third-party widgets) requires adding each vendor’s domain to the CSP, which is a maintenance burden.The strictest approach is nonce-based CSP: the server generates a unique nonce per request, includes it in the CSP header and on each script/style tag. This allows specific inline scripts while blocking injected ones. Next.js supports this natively with the nonce attribute on Script components.