Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I13
24 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I13 · Part 2 · Intermediate

Auth in React

Protected routes, token refresh, and the redirect that sends people back where they were.

3 min read7 sections2 exercises
24 of 57

In this chapter

  • Where the token should live
  • The provider
  • Protecting a route
  • Refreshing a token, without a stampede
  • Roles and permissions

and 2 more

about 3 min
07On this page
  1. Where the token should live
  2. The provider
  3. Protecting a route
  4. Refreshing a token, without a stampede
  5. Roles and permissions
  6. Logging out properly
  7. The rest of it, briefly

Where the token should live

StoreReadable by injected scriptSent automaticallyVerdict
localStorageyesnoCommon, and the weakest
Memory onlynonoSafe; lost on refresh
httpOnly cookienoyesThe default worth defending

A token in localStorage is readable by any script that gets onto your page — one compromised dependency and it is gone. An httpOnly cookie cannot be read by JavaScript at all, which removes that whole class. Its trade is CSRF, handled with SameSite=Lax or Strict plus a token on state-changing requests.

The pattern that gets both: a short-lived access token in memory, a long-lived refresh token in an httpOnly cookie. Nothing persistent is readable, and a refresh survives a page reload.

The provider

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [status, setStatus] = useState("loading");   // loading | in | out

  useEffect(() => {
    fetch("/api/me", { credentials: "include" })      // cookie goes with it
      .then((r) => (r.ok ? r.json() : null))
      .then((u) => { setUser(u); setStatus(u ? "in" : "out"); })
      .catch(() => setStatus("out"));
  }, []);

  const value = useMemo(() => ({ user, status, setUser }), [user, status]);
  return <AuthContext value={value}>{children}</AuthContext>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
  return ctx;
}
The three-state rule

user === null is ambiguous — it means both "not signed in" and "we have not checked yet". Without a separate status, the app flashes the login screen on every refresh before the check comes back. Three states, not a boolean.

Protecting a route

function RequireAuth({ children }) {
  const { status } = useAuth();
  const location = useLocation();

  if (status === "loading") return <FullPageSpinner />;
  if (status === "out") {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }
  return children;
}

Two details carry this. replace means the protected URL does not enter history — without it, pressing back from the login page returns to the guarded route, which bounces to login again, and the user is trapped. And state.from is what lets you send them onward afterwards:

async function onSubmit(credentials) {
  const user = await login(credentials);
  setUser(user);
  navigate(location.state?.from?.pathname ?? "/", { replace: true });
}

Landing on the dashboard when they clicked a link to an invoice is the small thing users notice. Sending them back to what they asked for is three lines.

A client guard is a UX affordance, not security. It hides a link, not the data. Every API request still has to be authorised on the server — anyone can edit the JavaScript.

Refreshing a token, without a stampede

let refreshing = null;                       // one in flight, shared

async function authedFetch(url, options = {}) {
  let res = await fetch(url, { ...options, credentials: "include" });

  if (res.status === 401) {
    refreshing ??= fetch("/api/refresh", { method: "POST", credentials: "include" })
      .finally(() => { refreshing = null; });

    const ok = (await refreshing).ok;
    if (!ok) { redirectToLogin(); throw new Error("Session expired"); }

    res = await fetch(url, { ...options, credentials: "include" });   // retry once
  }
  return res;
}

The shared refreshing promise is the whole point. A dashboard firing six requests at once gets six 401s; without it you send six refresh calls, five of which race and may invalidate each other's tokens. One in-flight refresh, everybody awaits it, everybody retries.

Retry once, never in a loop. If the retried request also returns 401, the session is genuinely gone — looping produces a refresh storm against your own auth service.

Roles and permissions

function Can({ permission, children, fallback = null }) {
  const { user } = useAuth();
  return user?.permissions?.includes(permission) ? children : fallback;
}

<Can permission="invoice:delete"><DeleteButton /></Can>

Check permissions, not roles. user.role === "admin" scattered through the UI has to be found and edited every time the model changes; invoice:delete is a fact the server can also enforce, in the same words.

Logging out properly

async function logout() {
  await fetch("/api/logout", { method: "POST", credentials: "include" });
  setUser(null);
  queryClient.clear();          // or the next user sees cached data
  navigate("/login", { replace: true });
}

Clearing the query cache is the step that gets skipped, and on a shared machine it means the next person sees the previous person's data until something refetches. Server-side session invalidation matters too — a logout that only forgets the token locally has not logged anyone out.

The rest of it, briefly

  • OAuth and social login. Use the authorisation code flow with PKCE; the implicit flow is deprecated. Never put a client secret in a browser bundle.
  • Multiple tabs. A storage event or a BroadcastChannel lets one tab's logout log the others out.
  • In a framework. Check the session in a layout or middleware, before rendering — then the protected page never reaches the browser at all, and the client guard becomes a convenience rather than the mechanism.
Interview answer, one sentence

"A short-lived access token in memory with a refresh token in an httpOnly cookie, three auth states rather than a boolean so refresh does not flash the login screen, and a guard that redirects with replace and carries from so people land where they were going. The detail that gets missed is a single shared refresh promise — otherwise six simultaneous 401s trigger six refreshes that race each other."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsIs the token about to expire?intermediate
  • 5 testsGate content behind a login, no router involvedintermediate
All 66 for this level →
I13

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I12Previous · I12State management libraries
  2. I13you are here
  3. Next · I14Server state with TanStack QueryTreat API data as a cache you do not own, and most of your state problems disappear.3 min read

01On this page

7 sections · 0%

  1. 01Where the token should live
  2. 02The provider
  3. 03Protecting a route
  4. 04Refreshing a token, without a stampede
  5. 05Roles and permissions
  6. 06Logging out properly
  7. 07The rest of it, briefly

[] chapters · t top