Auth is the part of a React app where most teams get most of it right and one part dangerously wrong. The wrong part is usually the same: tokens in localStorage, no refresh strategy, role checks scattered through the UI, and a backend that trusts whatever the client sends. It works in dev, passes review, and quietly enables account takeovers in production.
This is the condensed, opinionated production playbook. The full guide (all the code, diagrams, the OAuth2+PKCE walkthrough, MSAL setup, and the architect's checklist) is on my site 👇
AuthN vs AuthZ
Two different problems that share a header:
- Authentication — who are you? Verified once at login → a token.
- Authorization — what can you do? Checked on every request.
JWT — do this right or nothing else matters
A JWT is header.payload.signature. The signature is what makes it trustworthy — without verifying the signature on the server, a JWT is just JSON anyone can make up. The rules that aren't optional:
- Short lifetime — 5–15 min. Refresh tokens handle long sessions.
- Verify on the server, always — the client decodes for UI hints only; never trust client-side claims for authz.
- RS256 (asymmetric) for SPAs — server signs with a private key, APIs verify with the public key.
-
Always check
iss,aud,expon the server.
Where to store the access token
| Storage | XSS-safe? | Verdict |
|---|---|---|
localStorage |
❌ any XSS reads it | No — one XSS = account takeover |
sessionStorage |
❌ same risk | No |
| In-memory (module var / state) | ✅ | Yes — modern best practice |
HttpOnly cookie |
✅ JS can't read it | Yes (needs CSRF token) |
The 2026 pattern: access token in memory, refresh token in an HttpOnly cookie. On reload, hit /refresh to mint a new access token. Lost-on-reload is the cost; no-XSS-leak is the benefit.
// auth/tokenStore.ts — in-memory access token
let accessToken: string | null = null;
export const tokenStore = {
get: () => accessToken,
set: (t: string | null) => { accessToken = t; },
};
Refresh token rotation + reuse detection — the security cornerstone
Every refresh issues a new refresh token and invalidates the old one. If a revoked token is ever used again, it was stolen → revoke the entire chain immediately.
t=0 RT1 issued at login valid
t=10m /refresh with RT1 mint RT2, RT1 revoked
t=20m /refresh with RT2 mint RT3, RT2 revoked
t=21m attacker uses stolen RT1 reuse detected -> revoke chain, force re-login
This is OAuth 2.0 Refresh Token Rotation. Auth0, Okta, and Azure AD do it by default; if you roll your own, do this. Use a single in-flight refresh so a burst of 401s doesn't hammer /refresh 50x in parallel:
let pendingRefresh: Promise<string | null> | null = null;
export function tryRefresh() {
if (pendingRefresh) return pendingRefresh; // everyone shares one promise
pendingRefresh = fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
.then(async (r) => r.ok ? (await r.json()).access_token : null)
.finally(() => { pendingRefresh = null; });
return pendingRefresh;
}
RBAC — two layers, only one is real
UI LAYER (React) -> hides buttons the user can't use -> purpose: UX -> trust: NONE
SERVER LAYER (API) -> rejects requests the user can't make -> purpose: SECURITY -> trust: THE LINE
Client-side role checks are UX, not security. A user can open DevTools, edit React state, and reveal hidden buttons. That's fine — when they click, the server returns 403.
const PERMISSIONS = {
'post.write': ['editor', 'admin'],
'post.delete': ['admin'],
'billing.edit':['billing_admin', 'admin'],
} as const;
export function useCan() {
const { user } = useAuth();
return (p: keyof typeof PERMISSIONS) =>
!!user && user.roles.some((r) => (PERMISSIONS[p] as readonly string[]).includes(r));
}
// server — same role map, but binding
[Authorize(Policy = "post.delete")]
[HttpDelete("/posts/{id}")]
public Task<IActionResult> DeletePost(string id) { /* ... */ }
The SPA's permission table and the server's policies must derive from the same source of truth. When they drift, security holes appear silently.
OAuth 2.0 + PKCE — the modern login
PKCE (Proof Key for Code Exchange) is the SPA-safe extension that replaced the banned Implicit Flow. The SPA generates a code_verifier, sends SHA256(verifier) as the challenge, and proves ownership at token exchange. Without PKCE, an attacker who intercepts the code could redeem it. If a tutorial uses response_type=token, it's pre-2019 — don't follow it. For production, use the BFF (Backend For Frontend) pattern: a thin server completes the token exchange and sets the refresh token in an HttpOnly cookie the SPA never sees.
Azure AD / Entra ID via MSAL
For B2B / enterprise, Azure AD gives you SSO, MFA, conditional access, audit logs, and app-role claims that map straight to your RBAC — for free. MSAL wraps the OAuth2+PKCE flow and does silent refresh for you. One rule:
cache: { cacheLocation: 'memoryStorage' } // NOT localStorage, even though MSAL offers it
App Roles show up as a roles claim, so your useCan hook works unchanged. One RBAC table, two enforcement points, one identity source.
Production metrics (90-engineer SaaS migration)
| Metric | Before | After |
|---|---|---|
| Auth security incidents / qtr | 9 | 0 |
| Auth support tickets / qtr | 142 | 31 (−78%) |
| Session before re-login | 8 h | 30 days |
| Time to detect stolen token | never | < 1s |
| Custom auth code | ~2,400 lines | ~600 (−75%) |
| Time to add a new SSO customer | 2 weeks | 1 hour |
The mental model
Auth is a system, not a feature. The SPA, API, and IdP must agree on one vocabulary (roles, claims, tenant) and one trust boundary (the server). Three habits keep it boring: treat the client as untrusted, use short access tokens + rotating refresh + HttpOnly cookies, and use the platform (Auth0/Azure AD/Cognito) unless you can name a reason that survives a security review.
The full guide has the complete token store + AuthProvider, the apiFetch refresh-on-401 wrapper, route guards, the full OAuth2+PKCE code, MSAL React setup + app-role mapping, the 11-point security fix-list, and the architect's checklist:
Originally published on PrepStack.
Top comments (0)