Where to Store Tokens in a Browser: An Honest Comparison

·4 min read

spasecuritytokensxss

Ask where to store a JWT in a browser and you'll get "never use localStorage" followed by "use httpOnly cookies" — usually without the part where cookies introduce a different problem.

The honest version: there is no option that's simply safe. Each one trades one attack surface for another, and the right answer depends on what you're actually defending against.

The options

localStorage / sessionStorage

Any JavaScript on your origin can read it. That includes anything injected via XSS, and every third-party script you've added — analytics, session replay, support widgets, an ad tag someone added in a hurry.

An XSS payload here is one line, and the token can be exfiltrated before the user notices anything. Worse, localStorage persists across tabs and sessions, so the stolen token may be long-lived.

sessionStorage is the same exposure with a shorter window — cleared when the tab closes, not shared across tabs.

Use when: you have no backend to set cookies from, the token is short-lived, and you've genuinely audited your third-party script surface.

httpOnly cookies

JavaScript can't read an httpOnly cookie. That's a real improvement — an XSS payload can't exfiltrate the token.

But it can still use it. The browser attaches cookies automatically, so injected script can simply make authenticated requests from the victim's session. It can't steal the token to use later; it can do anything it wants right now. That's a meaningful reduction in blast radius, not immunity.

Cookies also reintroduce CSRF, because they're attached automatically to cross-site requests too. Mitigate with SameSite:

behaviour
SameSite=Strict never sent cross-site — breaks inbound links into authenticated pages
SameSite=Lax sent on top-level GET navigations only. The sensible default
SameSite=None always sent; requires Secure. Needed for genuine cross-site setups

Lax blocks the classic form-POST CSRF. If your API is on a different site and you need None, you need real CSRF tokens as well.

Use when: you control a backend on the same site as your frontend. This is the default recommendation for most apps.

In-memory only

Keep the token in a JavaScript variable. Never persisted, gone on refresh.

Best XSS profile of the client-side options — nothing to read from storage, and the token dies with the page. But a page refresh logs the user out, so you need silent re-authentication: a hidden iframe against the authorization server, or a refresh token in an httpOnly cookie.

Which means you're usually combining this with cookies anyway. That combination — access token in memory, refresh token in an httpOnly SameSite cookie — is a genuinely good design.

Use when: you want strong defaults and can implement silent refresh.

Backend-for-frontend (BFF)

The token never reaches the browser at all. Your backend completes the OAuth2 flow, stores tokens server-side, and gives the browser an ordinary session cookie. Frontend calls your backend; your backend calls the API with the real token.

This eliminates the entire category. There is no token in the browser to steal, and XSS gets an attacker the user's session — same as any classic server-rendered app — rather than a portable bearer token.

The cost is real: you need a backend, it becomes a stateful dependency, and you've added a hop to every API call.

Use when: the tokens are high-value, you have a backend already, or you're in a regulated environment. This is the strongest option and increasingly the recommended one for confidential clients.

The part that gets skipped

If you have XSS, you have a serious problem regardless of storage.

An attacker running script on your origin can read the DOM, log keystrokes, modify what the user sees, and make authenticated requests. Moving the token to an httpOnly cookie stops exfiltration; it doesn't stop the attacker acting as the user for as long as the page is open.

Storage choice is damage limitation. It is not a substitute for a strict Content-Security-Policy, output encoding, dependency review, and keeping third-party scripts off authenticated pages. Teams that get the storage debate right and the CSP wrong have optimized the wrong thing.

Practical ranking

  1. BFF — strongest, most work
  2. Access token in memory + refresh token in httpOnly/SameSite cookie — best balance for most SPAs
  3. httpOnly cookie for both — fine, mind CSRF
  4. localStorage — only with short-lived tokens and a clean script surface

And regardless of choice: short access token lifetimes, refresh token rotation, and a real CSP.

Checking your setup

If you're moving to cookies and your API is on another origin, credentialed cross-origin requests are where this usually breaks first — the CORS Tester will show you exactly which preflight or Access-Control-Allow-Credentials header is missing. The JWT Decoder shows the exp on your access tokens, which is the other half of the risk calculation.

For framework-specific setup, see the frontend integration guides.


Token handling you don't have to design

AuthAction's frontend SDKs handle storage, silent refresh, and rotation with sensible defaults for each framework.

Free, unlimited users. No credit card required.