I13 · Part 2 · Intermediate
Auth in React
Protected routes, token refresh, and the redirect that sends people back where they were.
07On this page
Where the token should live
| Store | Readable by injected script | Sent automatically | Verdict |
|---|---|---|---|
localStorage | yes | no | Common, and the weakest |
| Memory only | no | no | Safe; lost on refresh |
| httpOnly cookie | no | yes | The 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
storageevent or aBroadcastChannellets 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."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series