Skip to main content

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:

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

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.

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.

Creating an Authenticated Fetch Helper

Rather than manually attaching the token to every request, wrap fetch in a reusable helper:
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
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

Using Private Routes

Wrap your protected routes with the PrivateRoute component. Any <Route> nested inside inherits the authentication check automatically. App.jsx

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:

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.
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

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:
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.