XSS wins against both options·HttpOnly stops reading, not using·cookies stop at about 4 KB·in-memory plus refresh token, or a BFF

Why this debate never dies

Every few months the argument restarts somewhere. One camp: “never put tokens in localStorage, one XSS and it’s gone.” The other camp: “cookies bring back CSRF, and CSRF is worse.” Both statements are true, which is exactly why the thread never resolves. The actual answer depends on your threat model, your API topology and how fat your tokens are, and it’s more nuanced than either slogan.

One thing to get out of the way first, because it changes what “stolen” means: a JWT is readable by anyone who holds it. The header and payload are base64url-encoded JSON, no encryption involved, and we wrote up why base64 is not encryption in detail. Drop the middle segment of any token into our base64 decoder and read your own claims back, no server involved. Whatever storage you pick, treat the payload as public. The thing you’re protecting is the signature’s power to authenticate, not the contents.

What localStorage actually risks

localStorage is per-origin, survives forever, and is readable by any JavaScript running on the page. All of it. localStorage.getItem('token') is one line, and “any JavaScript” means more than your own code: every npm package in your bundle, every analytics and chat-widget script, every browser extension with page access, and whatever an XSS vulnerability injects.

That last group isn’t hypothetical. In June 2024 the polyfill.io CDN started serving malicious code after the domain changed owners; Sansec counted over 100,000 sites embedding it. Every one of those pages ran attacker-controlled JavaScript with full access to the origin, localStorage included. Your token storage is only as trustworthy as the sketchiest script tag on your page.

OWASP’s HTML5 security cheat sheet is blunt about it: don’t store session identifiers or sensitive data in local storage, because a single XSS can read all of it. And the theft is the bad kind. The attacker exfiltrates the token with one fetch to their own server and then uses it from their own infrastructure, at their own pace, until it expires. No further access to the victim needed.

What HttpOnly buys you, and what it costs

An HttpOnly cookie can’t be read from document.cookie, full stop. The flag is old, by the way: Microsoft shipped it in IE6 SP1 back in 2002, years before anyone said “SPA”. Add Secure and the cookie never travels over plain HTTP either. So far, strictly better.

The cost is that the browser attaches cookies automatically, which is precisely the behaviour CSRF exploits: another site makes your browser fire a request at your API, and the cookie rides along without the attacker ever seeing it. That risk is much smaller than it used to be, since Chrome defaults cookies to SameSite=Lax (we cover the details, and the exceptions, in our SameSite cookies guide). But Lax still sends cookies on top-level GET navigations, so a GET endpoint that changes state is still exposed. And if your API lives on a different origin, cookies drag in the credentialed-CORS rules, which forbid the * wildcard and need credentials: 'include' on every fetch; our CORS guide covers that corner.

Smaller costs, still real: cookies ride on every request to the domain, images and prefetches included, and you can’t selectively omit them from a plain fetch to the same origin. For an API on its own subdomain that’s fine. For a token in a cookie on your main domain, it’s a few hundred wasted bytes per asset request.

The part both camps skip: XSS is game over anyway

Here’s the argument that should end most localStorage-vs-cookies threads and somehow never comes up. Suppose you did everything right on the cookie side: HttpOnly, Secure, SameSite. Now an XSS lands on your page. The attacker’s script cannot read the token. It doesn’t need to. It runs on your origin, so it can call your API directly and the browser will attach the HttpOnly cookie to every request. The attacker is the logged-in user for as long as the tab is open. OWASP’s session management guidance makes the same point: with XSS on the page, moving the identifier into a cookie does not stop impersonation.

So the honest comparison isn’t “vulnerable vs safe”, it’s two flavours of losing:

  • localStorage loss: the token leaves the building. It works from the attacker’s machine, offline from your site, until expiry. Detection is hard because the requests come from somewhere else entirely.
  • HttpOnly loss: the attacker rides the victim’s session, but only while the victim’s browser has the page open, and every malicious request comes from the victim’s own IP through your normal frontend. Nastier to exploit at scale, easier to cut off.

HttpOnly reduces persistence and blast radius. It does not change the category of the failure. Which leads to the actual conclusion: the storage decision is damage limitation, and the real defense budget belongs on not having XSS in the first place. Content-Security-Policy, framework auto-escaping, dependency auditing. Boring, effective.

The 4 KB problem

There’s also a purely mechanical argument that gets ignored: JWTs are fat and cookies are small. RFC 6265 requires browsers to support at least 4096 bytes per cookie, and in practice that minimum is the maximum; Chrome silently drops a cookie whose name plus value exceeds it. Silently. Your login “works” and the session just never sticks.

Meanwhile a real-world JWT with a dozen claims, a permissions array and an RS256 signature (the signature alone is 342 base64 characters at 2048 bits) lands at 1 to 3 KB without trying hard. Add a refresh token and an ID token and cookie storage stops being an option before security even enters the discussion. The pain continues server-side: nginx defaults to 8 KB per request header line and answers oversized ones with a 400, and HTTP even has a dedicated status code for the situation, 431 Request Header Fields Too Large. We’ve debugged a login that only failed for admin users. Their permissions array pushed the cookie over 4 KB. Nobody suspects the cookie size for the first two hours.

What auth providers actually recommend

Notice that the vendors who deal with this at scale recommend neither localStorage nor plain cookie storage for SPA access tokens. Auth0’s token storage docs recommend keeping tokens in browser memory, and their SPA SDK’s default does exactly that, inside a web worker so even the main-thread code (and any XSS running there) has no direct handle on the token. A closure works as the fallback where workers don’t fit.

Memory doesn’t survive a page reload, which is where the second half of the pattern comes in: a refresh token with rotation. Every refresh issues a new refresh token and invalidates the used one, and reuse of an old token flags the family as compromised; that property is what makes persisting the refresh token defensible at all. Ideally the refresh token sits in an HttpOnly cookie scoped to the token endpoint, so the refresh credential gets cookie-grade protection while the access token stays out of reach in memory.

And the strongest option removes the browser from the equation: a BFF, backend for frontend. Tokens live server-side, the SPA holds one ordinary session cookie, and the BFF proxies API calls, attaching the access token on the way through. The IETF’s guidance for browser-based OAuth apps ranks this above every in-browser storage scheme, for the obvious reason that a token that never reaches the browser can’t be stolen from it. The price is an extra moving part you now operate.

Token lifetimes as damage control

Whatever storage you land on, lifetime is the multiplier on every mistake. A stolen access token that dies in 10 minutes is an incident; one that lives 30 days is a breach. With refresh rotation in place there’s no UX reason for long access tokens, and 5 to 15 minutes is the common working range.

The catch specific to JWTs: they’re stateless by design, so the server can’t revoke one early without keeping a denylist and checking it on every request. Which is server-side state, the thing JWTs were supposed to eliminate. Short lifetimes are the pragmatic answer: you don’t revoke, you wait out the clock, and you keep the clock short. Refresh tokens are the revocable half of the pair; killing the refresh token family on logout or on suspicion is cheap because refresh calls hit the auth server anyway.

Our decision table

After building each of these setups at least once:

SituationStore it like this
Server-rendered app, same-origin APIClassic session cookie: HttpOnly, Secure, SameSite=Lax. You may not need JWTs in the browser at all.
SPA with a backend you controlBFF: tokens stay server-side, browser gets one session cookie.
SPA against a cross-origin API, no BFF budgetAccess token in memory (web worker), rotating refresh token in an HttpOnly cookie.
Quick internal tool, low stakeslocalStorage is survivable, with an access token that expires in minutes.
Long-lived JWT in localStorage on a public siteNo. This is the one combination with no redeeming trade-off.

Our default when asked: BFF if you can afford the extra moving part, memory plus rotation if you can’t. And whichever row you’re in, spend the saved arguing time on CSP and dependency hygiene, because that’s where this fight is actually won.

Token storage questions

Is it safe to store a JWT in localStorage?

It is safe exactly as long as no attacker-controlled JavaScript ever runs on your page, which is a bet you should assume you will lose eventually. Any script on the page can read localStorage in one line, so a single XSS hole, a compromised npm dependency or a hijacked third-party tag can copy the token and use it from anywhere until it expires. OWASP’s HTML5 security cheat sheet advises against putting session identifiers or other sensitive data there for exactly this reason. If you do it anyway, keep the token short-lived.

Should I store my JWT in a cookie or in localStorage?

For most apps, a cookie with HttpOnly, Secure and SameSite=Lax is the better default, because JavaScript cannot read it and CSRF is largely handled by SameSite. The trade-offs are real though: cookies cap out around 4096 bytes, travel on every request, and need CORS credentials handling for cross-origin APIs. The pattern most auth providers recommend for SPAs avoids both options for the access token: keep it in memory and persist only a rotating refresh token, or move tokens server-side behind a BFF.

Does HttpOnly protect against XSS?

HttpOnly stops JavaScript from reading the cookie, and that is all it does. With XSS on your page, the attacker’s script can still make requests to your API and the browser will attach the HttpOnly cookie automatically, so the attacker acts as the logged-in user without ever seeing the token. What HttpOnly prevents is exfiltration: the attack only works while the victim has your page open, instead of handing over a credential that works from the attacker’s own machine for days.

Can anyone read the contents of a JWT?

Yes. The header and payload of a JWT are base64url-encoded JSON, not encrypted, so anyone who holds the token can decode and read every claim in it with a one-liner or any online decoder. The signature only proves the token wasn’t modified; it hides nothing. Never put email addresses, roles you consider secret, or any personal data into a JWT payload that you wouldn’t print in the browser devtools. Encrypted JWTs (JWE) exist but are rare in practice.

How long should a JWT access token live?

Minutes, not days. Common practice with a refresh token in place is 5 to 15 minutes for the access token, which shrinks the window in which a stolen token is useful. JWTs are stateless, so the server cannot revoke one before expiry without keeping a denylist, and a denylist quietly reintroduces the server-side state JWTs were supposed to remove. The longer the lifetime, the longer a theft goes unfixable.

Why is my JWT too big for a cookie?

Because RFC 6265 only guarantees 4096 bytes per cookie and browsers treat that as a hard ceiling, silently dropping anything bigger. A JWT carrying a dozen claims, permission arrays and an RSA signature reaches 2 to 4 KB quickly, and if you also want a refresh token and an ID token in cookies you are past the limit. Big cookies also ride along on every single request, and servers reject oversized headers: nginx defaults to 8 KB per header line and answers with a 400.

What is a BFF (backend for frontend) in authentication?

A BFF is a small server that sits between your SPA and your APIs and keeps all OAuth tokens on the server, where page scripts can never touch them. The browser only holds a classic session cookie (HttpOnly, Secure, SameSite), and the BFF exchanges it for the real access token on each proxied API call. The IETF’s guidance for browser-based OAuth apps ranks this as the strongest option, precisely because no token ever exists in the browser.

Is sessionStorage safer than localStorage for tokens?

Only marginally. sessionStorage is scoped to one tab and cleared when the tab closes, so a stolen token has a shorter shelf life and doesn’t leak across tabs. But the read path is identical: any JavaScript on the page can call sessionStorage.getItem, so the XSS threat model doesn’t change at all. Treat it as a convenience difference, not a security boundary. In-memory storage inside a closure or web worker is the version of this idea that actually removes the read path.