Topic #30Advanced18 min read

Authentication & Authorization

The complete auth story for the frontend: JWT vs sessions/cookies, where to store tokens safely (localStorage vs httpOnly), OAuth/OIDC, refresh-token rotation, XSS/CSRF defenses, and RBAC route guards — beginner to advanced in one page.

#auth#jwt#sessions#cookies#oauth#pkce#refresh-tokens#xss#csrf#rbac#security

Authentication vs authorization. Authentication (authn) proves who you are — logging in, validating a token. Authorization (authz) decides what you're allowed to do — roles and permissions. You authenticate once, then authorize every action. On the client, authz shows up as route guards and conditional UI, but it is only a hint — the server must re-check every protected request.

Two models: sessions vs JWTs. In session-based auth the server creates a session, stores state server-side, and hands the browser a session-id cookie; every request sends that cookie and the server looks up the session. In token-based (JWT) auth the server returns a signed JSON Web Token carrying claims (user id, roles, expiry); the client sends it in the Authorization: Bearer header and the server verifies the signature without any lookup. Sessions are stateful and trivially revocable; JWTs are stateless and scale horizontally but are hard to revoke before they expire.

What a JWT actually is. Three base64url parts joined by dots: a header (algorithm), a payload (claims like sub, role, exp), and a signature. It is signed, not encrypted — anyone can read the payload, so never put secrets in it. The signature only proves it wasn't tampered with. The server trusts it because it can verify the signature with its secret/public key.

The big question: where do you store the token? This is the most important — and most fumbled — decision. localStorage is easy (JS reads it, an interceptor attaches it) but is fully exposed to XSS: any injected script can steal every token. httpOnly cookies cannot be read by JavaScript, so XSS can't exfiltrate them, and the browser attaches them automatically — but cookies are sent on every request, which opens them to CSRF. In-memory (a JS variable) is XSS-resistant-ish and lost on refresh. There is no perfect option; you trade which attack you defend against.

The pragmatic recommendation. For most apps: keep the access token in memory (or accept localStorage for low-risk apps) and the refresh token in an httpOnly, Secure, SameSite cookie set by the backend. That way XSS can't steal the long-lived refresh token, and CSRF is contained because the refresh endpoint is the only cookie-authenticated route and can carry a CSRF token. Alternatively, do a full cookie-session model (httpOnly session cookie + CSRF token) and skip client-side token handling entirely.

XSS is the token-theft threat. Cross-Site Scripting = attacker-injected JavaScript running in your origin. If your token lives anywhere JS can read it, XSS steals it. Defenses: never dangerouslySetInnerHTML untrusted data, rely on React's default escaping, set a strict Content-Security-Policy, and prefer httpOnly cookies so the token is out of JS's reach. XSS is why 'just use localStorage' is not a safe default for sensitive apps.

CSRF is the cookie threat. Cross-Site Request Forgery = a malicious site tricks the browser into sending your auth cookie on a state-changing request (the browser attaches cookies automatically). It only matters when you authenticate with cookies. Defenses: SameSite=Lax/Strict cookies (the modern baseline), a synchronizer CSRF token (double-submit or header), and checking the Origin/Referer. Bearer-token-in-header auth is naturally CSRF-immune because the browser doesn't auto-attach the Authorization header — which is the trade-off against XSS.

Refresh tokens and rotation. Access tokens are kept short-lived (minutes) to limit damage if leaked; a long-lived refresh token silently obtains a new access token without re-login. Rotation issues a new refresh token on each use and invalidates the old one, so a stolen refresh token is detectable (reuse of an old one is a breach signal). Store refresh tokens in httpOnly cookies and rotate them.

Token refresh via a response interceptor. The clean pattern: catch a 401, guard with a _retry flag so you refresh at most once, call the refresh endpoint, store the new access token, update the failed request's Authorization header, and replay the original request — making expiry invisible to the app. The _retry flag prevents an infinite loop when the refresh itself fails, in which case you log the user out. When many requests fail at once, queue them behind a single in-flight refresh so you don't fire N refreshes.

OAuth 2.0 and OIDC. OAuth 2.0 is a delegation protocol ('let this app act on my behalf'); OpenID Connect layers identity (an ID token) on top for 'log in with Google/GitHub'. For SPAs the correct flow is Authorization Code with PKCE — the implicit flow is deprecated because it leaked tokens in the URL. The app redirects to the provider, the user consents, the provider redirects back with a short-lived code, and the app exchanges the code (plus a PKCE verifier) for tokens. You then send the ID token to your backend to verify and mint your session — never trust a provider token blindly on the client.

Authorization on the client: route guards and RBAC. Role-Based Access Control gates routes and UI: an AdminRoute reads the current user, redirects to /login if unauthenticated and to /unauthorized if the role is wrong, and conditional rendering hides buttons the user can't use. This is UX only. Anyone can edit the JS, call the API directly, or forge a request — so the server must independently authorize every protected endpoint. Client RBAC improves experience; it is never a security boundary.

Logout and expiry. Logout must clear the access token, tell the backend to revoke/rotate-out the refresh token (you can't 'delete' a stateless JWT, so revocation needs a server-side denylist or short expiry), and clear any auth state/caches. On expiry, the interceptor either refreshes silently or, if refresh fails, redirects to login with the intended URL preserved so the user returns where they were.

The mental model (memorise this). Authn = who you are (session cookie or Bearer JWT); authz = what you can do (server-enforced, client-hinted). Store the long-lived secret where JS can't read it (httpOnly cookie) to beat XSS, and defend cookie auth against CSRF (SameSite + token). Keep access tokens short, refresh silently in an interceptor with rotation, use Authorization-Code-with-PKCE for OAuth, and treat client-side RBAC as UX — the backend is always the real gate.

Backend Analogy

The Bearer-JWT-on-every-request pattern is a stateless Spring Security filter chain validating a token per request — no `HttpSession` needed, which is why it scales like your stateless REST services. Session cookies are the classic `HttpSession` + `JSESSIONID` model: revocable but requiring shared session storage (a sticky-session or Redis-backed store) to scale, exactly the trade-off you weigh server-side. Refresh-token rotation is the short-lived-access + long-lived-refresh scheme you'd implement with a token store and a reuse-detection denylist. OAuth's Authorization-Code-with-PKCE is what Spring Authorization Server / `spring-security-oauth2-client` orchestrates. And client RBAC route guards are the mirror of `@PreAuthorize("hasRole('ADMIN')")` — but only the server's `@PreAuthorize` is authoritative; the frontend guard just hides the button.

Key Insights
  • Authentication proves who you are; authorization decides what you can do. Client authz is a UX hint — the server enforces it on every request.
  • Sessions are stateful and easily revoked but need shared session storage; JWTs are stateless and scale but are hard to revoke before expiry.
  • A JWT is signed, not encrypted — anyone can read the payload, so never put secrets in it; the signature only proves integrity.
  • Where you store the token is the key decision: localStorage is XSS-exposed, httpOnly cookies are XSS-safe but CSRF-exposed, in-memory is safer but lost on refresh.
  • Pragmatic default: access token in memory, refresh token in an httpOnly + Secure + SameSite cookie set by the backend.
  • XSS steals tokens JS can read — defend with output escaping, CSP, and httpOnly cookies. CSRF abuses auto-sent cookies — defend with SameSite plus a CSRF token.
  • Bearer-in-header auth is CSRF-immune (the browser doesn't auto-attach it) but XSS-vulnerable; cookie auth is the reverse — pick your threat.
  • Keep access tokens short-lived; use a long-lived, rotating refresh token to renew silently, with reuse detection to spot theft.
  • Refresh via a response interceptor with a _retry guard and a single queued in-flight refresh so a 401 storm doesn't fire N refreshes or loop forever.
  • For OAuth in SPAs use Authorization Code with PKCE (implicit is deprecated); verify the provider token on YOUR backend and mint your own session.

Worked Code

Storage trade-offs: localStorage vs httpOnly cookie
TypeScript
// --- Option A: localStorage (easy, but XSS can read it) ---
localStorage.setItem('accessToken', token);
api.interceptors.request.use(c => {
  const t = localStorage.getItem('accessToken');   // <-- any injected script can too
  if (t) c.headers.Authorization = `Bearer ${t}`;
  return c;
});

// --- Option B: httpOnly cookie (JS CANNOT read it; XSS-safe) ---
// The BACKEND sets it on login:
//   Set-Cookie: refresh=...; HttpOnly; Secure; SameSite=Strict; Path=/auth
// The browser attaches it automatically; you just enable credentials:
const api = axios.create({ baseURL: '/api', withCredentials: true });
// No interceptor needed for the cookie — but now protect against CSRF (below).
JWT login + silent refresh via response interceptor
TypeScript
import axios from 'axios';

// Access token kept in memory (not localStorage) to reduce XSS exposure
let accessToken: string | null = null;

async function login(email: string, password: string) {
  // Backend sets the refresh token as an httpOnly cookie AND returns the access token
  const { data } = await api.post('/auth/login', { email, password });
  accessToken = data.accessToken;
  return data.user;
}

api.interceptors.request.use(c => {
  if (accessToken) c.headers.Authorization = `Bearer ${accessToken}`;
  return c;
});

// Queue concurrent 401s behind ONE refresh so we don't fire many refreshes
let refreshing: Promise<string> | null = null;

api.interceptors.response.use(
  res => res,
  async error => {
    const original = error.config;
    if (error.response?.status === 401 && !original._retry) {
      original._retry = true; // refresh at most once per request
      try {
        refreshing ??= axios
          .post('/auth/refresh', {}, { withCredentials: true }) // cookie carries refresh token
          .then(r => (accessToken = r.data.accessToken));
        const fresh = await refreshing;
        refreshing = null;
        original.headers.Authorization = `Bearer ${fresh}`;
        return api(original); // replay the original request
      } catch (e) {
        refreshing = null;
        accessToken = null;
        window.location.href = '/login'; // refresh failed -> log out
        return Promise.reject(e);
      }
    }
    return Promise.reject(error);
  },
);
OAuth 2.0 Authorization Code with PKCE (the SPA-correct flow)
TypeScript
// 1) Build a PKCE challenge and redirect the user to the provider
async function startLogin() {
  const verifier = crypto.randomUUID() + crypto.randomUUID(); // random secret
  sessionStorage.setItem('pkce_verifier', verifier);

  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); // base64url

  const params = new URLSearchParams({
    client_id: 'my-spa',
    redirect_uri: window.location.origin + '/callback',
    response_type: 'code',            // Authorization Code (NOT implicit)
    scope: 'openid profile email',
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });
  window.location.href = `https://auth.example.com/authorize?${params}`;
}

// 2) On /callback, exchange the code (+ verifier) for tokens, then hand the
//    ID token to YOUR backend to verify and mint your own session.
async function handleCallback(code: string) {
  const verifier = sessionStorage.getItem('pkce_verifier')!;
  const { data } = await axios.post('https://auth.example.com/token', {
    grant_type: 'authorization_code', code, code_verifier: verifier,
    client_id: 'my-spa', redirect_uri: window.location.origin + '/callback',
  });
  await api.post('/auth/oauth', { idToken: data.id_token }); // backend verifies + sets session
}
CSRF defense (double-submit token) + RBAC route guard
TSX
// CSRF: when using cookie auth, echo a CSRF token in a header.
// The backend compares the header value to a token bound to the session.
api.interceptors.request.use(c => {
  const csrf = getCookie('XSRF-TOKEN');        // readable, non-httpOnly cookie
  if (csrf) c.headers['X-XSRF-TOKEN'] = csrf;  // attacker's site can't read it -> can't forge
  return c;
});

// RBAC route guard — UX ONLY; the server still authorizes every request
import { Navigate } from 'react-router-dom';

function AdminRoute({ children }: { children: React.ReactNode }) {
  const { user, loading } = useAuth();
  if (loading) return <Spinner />;
  if (!user) return <Navigate to="/login" replace />;          // not authenticated
  if (user.role !== 'admin') return <Navigate to="/unauthorized" replace />; // wrong role
  return <>{children}</>;
}

// Conditional UI is also just a hint, never a security boundary:
// {user.role === 'admin' && <DeleteButton />}

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

Decode a JWT payload in the browser (signed, not encrypted!)

Interview-Ready Q&A

Authentication verifies identity — proving who the user is (login, token validation). Authorization determines what an authenticated user may do (roles, permissions). You authenticate first, then authorize each action. On the client, authorization appears as route guards and conditional UI, but it must be enforced on the server; the client version is only a UX convenience.

Things to Remember
  • 1Authn = who you are; authz = what you can do. Client authz is UX only; the backend is the real gate.
  • 2Sessions: stateful, revocable, need shared storage. JWTs: stateless, scalable, hard to revoke — a signed (not encrypted) token.
  • 3Token storage trade-off: localStorage = XSS-exposed; httpOnly cookie = XSS-safe but CSRF-exposed; in-memory = safer, lost on refresh.
  • 4Default: access token in memory, refresh token in httpOnly + Secure + SameSite cookie.
  • 5XSS steals JS-readable tokens (defend: escaping, CSP, httpOnly). CSRF abuses auto-sent cookies (defend: SameSite + CSRF token).
  • 6Short access tokens + rotating refresh tokens; refresh silently in a response interceptor with a _retry guard and a single queued refresh.
  • 7OAuth for SPAs: Authorization Code with PKCE (implicit is deprecated); verify provider token on your backend.
  • 8RBAC route guards and conditional UI are hints — the server authorizes every protected endpoint.

References & Further Reading