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
- BFF — strongest, most work
- Access token in memory + refresh token in httpOnly/SameSite cookie — best balance for most SPAs
- httpOnly cookie for both — fine, mind CSRF
- 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.