React Auth Patterns
โฑ๏ธ ~6-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ข Auth context = building keycard system. The card reader (AuthProvider) issues a badge on login. Every door (ProtectedRoute) checks your badge before letting you in. When the badge expires, the system silently renews it at the visa office (/auth/refresh) โ you never notice the swap.
๐๏ธ
AuthProvider
Issues the badge. Stores user + token in context. One source of truth for the whole app.
๐ช
ProtectedRoute
The door reader. Checks badge before rendering. Redirects to login if missing.
๐
Silent Refresh
The auto-renewal desk. Exchanges your expiring badge before it runs out โ no interruption.
Token storage security spectrum
Interactive Sandbox
โ Move something, see it react instantly.Pattern
Auth state machine โ click to transition
๐ Unauthenticated
No user session. App shows public content and login prompt.
Transitions to:
| 1 | import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; |
| 2 | ย |
| 3 | interface User { id: string; email: string; role: 'admin' | 'user' } |
| 4 | interface AuthCtx { |
| 5 | user: User | null; |
| 6 | token: string | null; |
| 7 | login: (email: string, password: string) => Promise<void>; |
| 8 | logout: () => void; |
| 9 | } |
| 10 | ย |
| 11 | const AuthContext = createContext<AuthCtx | null>(null); |
| 12 | ย |
| 13 | // Provider โ place at the root of your app |
| 14 | export function AuthProvider({ children }: { children: ReactNode }) { |
| 15 | const [user, setUser] = useState<User | null>(null); |
| 16 | const [token, setToken] = useState<string | null>(null); |
| 17 | ย |
| 18 | const login = useCallback(async (email: string, password: string) => { |
| 19 | const res = await fetch('/api/auth/login', { |
| 20 | method: 'POST', |
| 21 | body: JSON.stringify({ email, password }), |
| 22 | headers: { 'Content-Type': 'application/json' }, |
| 23 | }); |
| 24 | if (!res.ok) throw new Error('Invalid credentials'); |
| 25 | const { user, accessToken } = await res.json(); |
| 26 | setUser(user); |
| 27 | setToken(accessToken); // keep token in memory โ never localStorage |
| 28 | }, []); |
| 29 | ย |
| 30 | const logout = useCallback(() => { |
| 31 | setUser(null); |
| 32 | setToken(null); |
| 33 | }, []); |
| 34 | ย |
| 35 | return ( |
| 36 | <AuthContext.Provider value={{ user, token, login, logout }}> |
| 37 | {children} |
| 38 | </AuthContext.Provider> |
| 39 | ); |
| 40 | } |
| 41 | ย |
| 42 | // Hook โ every component that needs auth calls this |
| 43 | export function useAuth() { |
| 44 | const ctx = useContext(AuthContext); |
| 45 | if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>'); |
| 46 | return ctx; |
| 47 | } |
| 48 | ย |
| 49 | // Usage in any component โ no props needed |
| 50 | function ProfileButton() { |
| 51 | const { user, logout } = useAuth(); |
| 52 | return user |
| 53 | ? <button onClick={logout}>{user.email}</button> |
| 54 | : <a href="/login">Sign in</a>; |
| 55 | } |
When to use
Any React app where multiple components need access to the current user. Context prevents prop drilling 5 levels deep. Combine with a hook so components never import the context object directly.
Challenge
Explore all 5 React auth patterns. Step through the auth state machine to see how each pattern connects.
Why Should I Care?
โ The exact interview question + the bug it kills.Interview questions
Q: Why not store JWT in localStorage?
localStorage is readable by any JavaScript on the page. An XSS vulnerability โ even in a third-party script โ lets an attacker call localStorage.getItem('token') and exfiltrate the token. httpOnly cookies are completely invisible to JS โ the browser sends them automatically but no script can read them. For access tokens, in-memory React state is the best compromise: invisible to XSS, no persistence needed (silent refresh on page load restores the session).
Q: Why put auth in context instead of prop-drilling?
Prop drilling auth through 5+ component levels creates tight coupling, forces intermediate components to accept props they don't use, and makes refactoring painful. Context creates a single source of truth โ any component calls useAuth() and gets the current user without knowing where it lives in the tree. A single setUser() in the provider propagates everywhere.
Q: When to use React Query vs raw fetch for auth?
React Query excels at data fetching with caching, retries, and background refresh โ but auth state has different requirements. Auth needs to survive across the whole app (not per-query), drive routing decisions, and handle token refresh as a cross-cutting concern. Use React Query for data endpoints that sit behind the auth layer. Use custom context + Axios interceptors for the auth flow itself โ you need precise control over the 401 retry loop and the token refresh queue.
The Deep Dive
โ Spec refs, engine internals, the minutiae.AuthContext with TypeScript generics
The context can be typed generically so different apps extend the User type without rewriting the provider. The AuthContext<T extends BaseUser> pattern lets you add role, permissions, or orgId fields at the app level while the provider stays generic.
| 1 | interface BaseUser { id: string; email: string; } |
| 2 | ย |
| 3 | function createAuthContext<T extends BaseUser>() { |
| 4 | const Ctx = createContext<AuthCtx<T> | null>(null); |
| 5 | function useAuth() { |
| 6 | const ctx = useContext(Ctx); |
| 7 | if (!ctx) throw new Error('useAuth outside provider'); |
| 8 | return ctx; |
| 9 | } |
| 10 | return { Ctx, useAuth }; |
| 11 | } |
| 12 | ย |
| 13 | // Per-app usage: |
| 14 | interface AppUser extends BaseUser { role: 'admin' | 'viewer'; orgId: string; } |
| 15 | export const { Ctx: AuthCtx, useAuth } = createAuthContext<AppUser>(); |
Axios request + response interceptors
The request interceptor attaches the Bearer token to every outgoing API call. The response interceptor catches 401 errors and retries with a refreshed token. The isRefreshing flag and queue prevent multiple simultaneous refresh calls when many requests expire at once.
| 1 | // Attach token to every request |
| 2 | api.interceptors.request.use((config) => { |
| 3 | const token = tokenStore.get(); |
| 4 | if (token) config.headers.Authorization = `Bearer ${token}`; |
| 5 | return config; |
| 6 | }); |
| 7 | ย |
| 8 | // Catch 401 โ refresh โ retry |
| 9 | api.interceptors.response.use( |
| 10 | (res) => res, |
| 11 | async (err) => { |
| 12 | if (err.response?.status === 401 && !err.config._retried) { |
| 13 | err.config._retried = true; |
| 14 | const newToken = await silentRefresh(); // calls /api/auth/refresh |
| 15 | tokenStore.set(newToken); |
| 16 | err.config.headers.Authorization = `Bearer ${newToken}`; |
| 17 | return api(err.config); |
| 18 | } |
| 19 | return Promise.reject(err); |
| 20 | } |
| 21 | ); |
React Router v6 loader pattern for auth-gated routes
React Router v6 loaders run before the component renders. Checking auth in the loader means the redirect happens before any component mounts โ no flash of protected content.
| 1 | import { redirect } from 'react-router-dom'; |
| 2 | ย |
| 3 | // Loader runs before the component โ redirect is server-style |
| 4 | async function dashboardLoader() { |
| 5 | const user = await getCurrentUser(); // check session |
| 6 | if (!user) return redirect('/login'); |
| 7 | return { user }; // passed to useLoaderData() |
| 8 | } |
| 9 | ย |
| 10 | const router = createBrowserRouter([ |
| 11 | { |
| 12 | path: '/dashboard', |
| 13 | loader: dashboardLoader, |
| 14 | element: <Dashboard />, |
| 15 | }, |
| 16 | ]); |
PKCE flow for SPAs โ why implicit flow is deprecated
OAuth 2.0 Implicit flow returned the access token directly in the URL fragment โ readable by browser history, server logs, and Referer headers. PKCE (Proof Key for Code Exchange) fixes this: the SPA generates a random code_verifier, hashes it to a code_challenge, sends the challenge to the auth server, then exchanges the authorization code for tokens by proving it knows the original verifier. The token never appears in the URL. RFC 9700 (2025) deprecates implicit flow entirely โ all SPAs must use PKCE.
Interview Questions
โ Real questions from real interviews โ with answers.XSS can steal anything in localStorage โ httpOnly cookies are JS-invisible.
Memoize the context value with useMemo so only changed fields trigger consumers.
Serializes concurrent 401s into a single refresh call to prevent token rotation race conditions.
Client-side guards can be bypassed in DevTools; only the server enforces real access control.
Refresh tokens live in a DB row that can be deleted; short access token TTL limits the revocation gap.
React Query for user profile data; AuthContext for identity, routing decisions, and cross-cutting token management.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is the purpose of a Content Security Policy (CSP) header in an auth context?