DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on Originally published at prepstack.co.in

React Authentication & Authorization — JWT, Refresh Token Rotation, RBAC, Azure AD, OAuth2 + PKCE (Real Code)

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 👇

Full guide: https://prepstack.co.in/blog/react-authentication-authorization-jwt-refresh-tokens-rbac-azure-ad-oauth2-guide

AuthN vs AuthZ

Two different problems that share a header:

  • Authenticationwho are you? Verified once at login → a token.
  • Authorizationwhat 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, exp on 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; },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode
// server — same role map, but binding
[Authorize(Policy = "post.delete")]
[HttpDelete("/posts/{id}")]
public Task<IActionResult> DeletePost(string id) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

https://prepstack.co.in/blog/react-authentication-authorization-jwt-refresh-tokens-rbac-azure-ad-oauth2-guide

Originally published on PrepStack.

Top comments (0)