JWT Storage: localStorage, Cookies, and the XSS Problem
Where to actually store JWT tokens — localStorage, sessionStorage, httpOnly cookies, or memory. The XSS vs CSRF tradeoffs that decide it.
An authentication bug hits a production app. A user reports that logging out on their laptop didn't log them out on their phone. The engineer opens DevTools, types localStorage.getItem('token'), and sees a full JWT sitting in plain text. That token was placed there by the login form, and nothing in the app has touched it since — but a cross-site scripting flaw anywhere in the codebase would hand it over in three characters.
Token storage is one of those decisions that gets copy-pasted out of tutorials and never revisited. The tutorial says "save it to localStorage." The app ships. Three years later, the attack surface is still there, and the engineers who inherit it have no idea why that choice was made.
This post compares the four real options and the tradeoffs each one makes. There isn't one correct answer — there's a correct answer for your app.
The Four Places a JWT Can Live
When a browser holds an access token, it has four practical storage choices:
- localStorage — persistent key-value storage, readable by any JavaScript on the same origin
- sessionStorage — same as localStorage but scoped to the browser tab; cleared when the tab closes
- httpOnly cookies — sent automatically with every request to the matching domain; invisible to JavaScript
- In-memory JavaScript variables — the token exists only while the page is loaded; lost on refresh or navigation
The choice between them is a balance between two attack classes: cross-site scripting (XSS) and cross-site request forgery (CSRF). Any option that JavaScript can read is vulnerable to XSS token theft. Any option that the browser sends automatically is vulnerable to CSRF unless protected. There is no storage mechanism that avoids both.
Why "Just Use localStorage" Is the Most-Given Worst Advice
localStorage is popular because it's simple. Three lines of code and the token is saved and retrievable. It persists across tabs, survives refreshes, and doesn't need any server cooperation.
That same simplicity is the problem. Any successful XSS payload — an unsanitized user comment, a compromised third-party script, a vulnerable dependency with a DOM-manipulation bug — can execute localStorage.getItem('token') and exfiltrate the JWT to an attacker-controlled domain. The OWASP HTML5 Security Cheat Sheet is blunt about this: "Do not store sensitive information in Web Storage."
The common counterargument is that httpOnly cookies are also compromised under full XSS — an attacker with JavaScript execution can issue authenticated requests on your behalf, even if they can't read the cookie directly. That's true but misses a key distinction. With httpOnly cookies, the attacker is constrained to what they can do inside the browser session while the tab is open. With localStorage, the token itself is exfiltrated and can be used from anywhere, including long after the user closes the tab.
Exfiltrating a token to an external server is a fundamentally different threat than temporarily riding an existing session. The former scales; the latter is bounded.
httpOnly Cookies and the SameSite Attribute
httpOnly cookies move the JWT out of JavaScript's reach entirely. The server sets a cookie with the HttpOnly flag, and the browser enforces that no script can read the value via document.cookie. The browser still sends the cookie automatically with every matching request — which is where CSRF enters the picture.
The SameSite attribute is the first line of CSRF defense, and its behavior across the three values is worth understanding before picking one:
| SameSite value | Cross-origin top-level GET | Cross-origin POST (form submit) | Cross-origin fetch |
|---|---|---|---|
| Strict | Not sent | Not sent | Not sent |
| Lax (default) | Sent | Not sent | Not sent |
| None; Secure | Sent | Sent | Sent |
Behavior per RFC 6265bis draft and current browser implementations (Chrome, Firefox, Safari).
Chrome made SameSite=Lax the default for all cookies without an explicit value starting in Chrome 80 (February 2020). That change alone eliminated the most naive class of CSRF attacks — a malicious site posting a hidden form to your bank no longer carries your cookies. But Lax still allows top-level GET navigation, so any state-changing endpoint that accepts GET is exposed. (It shouldn't accept GET, but plenty do.)
Practical rule: set SameSite=Lax as your default. Use Strict for high-sensitivity operations like password changes or funds transfers. Use None; Secure only when your API genuinely needs to support cross-site embedded requests and you have separate CSRF token protection in place.
The Refresh Token Pattern
Access tokens should be short-lived. A typical access token is 5-15 minutes; a refresh token might last 7-30 days. This separation lets you limit the blast radius of a leaked access token without forcing users to log in every 10 minutes.
The refresh flow looks like this:
- User logs in. Server returns a short-lived access token and sets a long-lived refresh token as an httpOnly cookie.
- Access token is kept in memory only (not persisted). API requests use
Authorization: Bearer <token>. - When the access token expires, the client calls
/auth/refresh. The browser sends the refresh cookie automatically. The server issues a new access token. - On page refresh, the in-memory access token is gone. The client calls
/auth/refreshon app startup to silently restore session.
The refresh token never touches JavaScript. The access token lives in memory only — XSS can read it while the tab is open, but can't persist it. And because the refresh cookie is SameSite=Strict, a malicious site can't trigger the refresh flow itself.
Refresh token rotation is a useful addition: each refresh call issues a new refresh token and invalidates the old one. If an attacker steals a refresh token but the real user uses their old one first, the old token works once and then both are invalidated — a detectable anomaly that signals compromise. The OAuth 2.0 specification (RFC 6749) describes this pattern, and it's now standard in Auth0, Okta, and most identity providers.
CSRF Token Pairing for Cookie-Based Auth
Even with SameSite=Lax, CSRF defense-in-depth usually means pairing the auth cookie with a CSRF token. The most common pattern is the double-submit cookie: the server sets both the auth cookie (httpOnly) and a CSRF token cookie (not httpOnly, readable by JS). For state-changing requests, the client reads the CSRF cookie and sends it as a custom header like X-CSRF-Token. The server checks that the header value matches the cookie value.
This works because an attacker making a cross-site request can carry cookies but cannot read them (due to same-origin policy) and cannot set arbitrary custom headers on a cross-origin request without CORS approval. Browser same-origin enforcement does the heavy lifting.
The Decision Table
Here is what to pick based on what you're building:
| Application type | Recommended storage | Why |
|---|---|---|
| Traditional web app (same-origin API) | httpOnly cookie with SameSite=Lax + CSRF token | Browser handles transmission; XSS can't steal the token |
| SPA calling same-origin API | Access token in memory, refresh token as httpOnly cookie | Bounded XSS exposure, clean refresh flow |
| SPA calling cross-origin API | Access token in memory, refresh via BFF (backend-for-frontend) proxy | Avoids SameSite=None; BFF holds the refresh cookie |
| Mobile native app (via WebView) | Platform secure storage (Keychain / Keystore) | localStorage attacks don't apply; OS-level isolation |
| Internal admin tool (trusted users, short sessions) | sessionStorage (with acknowledged risk) | Simpler implementation; tab-scoped bounds the window |
The Mistake Worth Naming
The single most common mistake in JWT storage isn't any of the above — it's treating the choice as "pick one" when the real answer is architectural. If your auth flow can't survive a page refresh without localStorage, your refresh token flow isn't set up. If you can't use httpOnly cookies because "the mobile team needs the same API," that's a BFF problem, not a storage problem.
Inspecting the actual contents of your JWT is also worth doing periodically — you can paste a token into our JWT debugger to see what claims you're putting on the wire. A surprising number of production JWTs include email addresses, full names, and internal user IDs that never needed to be client-side visible in the first place.
One More Thing: Logout
A design choice that cookies handle better than localStorage: logout. To log a user out of a localStorage-based app, you can clear the token — but any tab that already has the token in memory still works until its own refresh. With httpOnly cookies, the server can invalidate the session record and the next request fails. The token stops working everywhere, at once.
For apps with regulatory requirements around session termination (HIPAA, some financial regulations), this difference matters enough to drive the storage choice by itself.
For further reading, the OWASP Session Management Cheat Sheet is the most complete practical reference. The JWT spec (RFC 7519) is short and readable if you haven't skimmed it. And password entropy covers a related question: how strong the credentials producing these tokens need to be in the first place.