ESC

Type to search the knowledge base.

JWT Storage Pitfalls

Why localStorage JWTs are XSS bait, cookie alternatives, refresh patterns, and SPA session designs that age better.

intermediate3 min read
  • security
  • jwt
  • localStorage
  • xss
  • auth

JWTs are a token format, not an auth architecture. The recurring frontend bug is: put a long-lived access JWT in localStorage, read it on every fetch, and assume you’re “stateless and modern.” Any XSS then exfiltrates the session in one line.

Docs: OWASP JWT, web.dev storage, XSS.

The anti-pattern

// Fragile
localStorage.setItem('access_token', jwt);
await fetch('/api', {
  headers: { Authorization: `Bearer ${localStorage.getItem('access_token')}` },
});
Property Effect
JS-readable XSS → full account takeover
Persists Survives tab close until cleared
Often long-lived Increases theft value

Better defaults

Browser stores opaque session id; JS never sees it. API authenticates cookie. Pair with CSRF defenses — CSRF.

let accessToken = null; // memory only

async function api(path, opts) {
  const res = await fetch(path, {
    ...opts,
    headers: { ...opts?.headers, Authorization: `Bearer ${accessToken}` },
  });
  if (res.status === 401) {
    await refresh(); // uses HttpOnly refresh cookie
    return api(path, opts);
  }
  return res;
}

Refresh token: HttpOnly; Secure; SameSite=Strict on a dedicated refresh endpoint. Access token dies on reload unless you re-bootstrap (call refresh on boot).

3. Avoid JWT in localStorage for session

If you must use web storage (legacy), keep TTLs tiny and accept XSS = game over — invest harder in CSP/Trusted Types.

JWT-specific footguns

  1. Sensitive PII in payload — base64 is not encryption; anyone with the token reads claims.
  2. alg=none / weak verification — server issue, know it exists.
  3. No revocation — pure JWT without server denylist makes theft durable until expiry.
  4. Putting tokens in query strings — logs and Referer leaks.

XSS vs CSRF tradeoff (interview gold)

Storage XSS impact CSRF impact
localStorage JWT High Low (header not auto-sent)
Cookie session Lower if HttpOnly Must mitigate CSRF

There is no free lunch — pick the threat model.

Interview out-loud

“I don’t put long-lived JWTs in localStorage because XSS steals them. Prefer HttpOnly cookies or memory-only access tokens with HttpOnly refresh. JWT is a format; storage and TTL decisions are the real security design.”

Reviewer prompts

  • What is the asset (session, PII, money movement)?
  • What is the attacker capability (web, XSS, network, dependency)?
  • Which control fails closed if misconfigured?
  • Is the server still enforcing authz?
  • Any new third party, iframe, or URL sink?

Residual risk note

Browser controls reduce likelihood and impact; they do not eliminate bugs. Prefer defense in depth: safe defaults in code, strict headers, and monitoring (CSP reports, auth anomaly alerts). When product pressure weakens a control, write down the accepted risk and a revisit date.

Further depth

Teams often under-invest in this topic until an incident or CWV regression. Schedule a one-hour drill: reproduce the failure mode in DevTools, list the top three mitigations for your stack, and file tickets with owners. Revisit after the next major feature that touches networking, rendering, auth, or third parties — those are the moments regressions land. Keep primary documentation links in the runbook so on-call is not searching chat history at 2am.

Concrete artifacts to leave behind: a short architecture note, a CI assertion or header snapshot, and a dashboard panel (lab or field) that would have caught the last bug. Teaching the rest of the team the mental model matters as much as the one-line fix.

Further reading

Related guides