I've seen this pattern in so many codebases — including my own, early on. You build a login flow, get back a JWT from the server, and then do the most convenient thing:
It works! The token persists across page refreshes, it's easy to read back, and you ship the feature. But this is one of those things that feels fine until it really, really isn't.
Why localStorage Is a Security Liability
The core problem is Cross-Site Scripting (XSS). Any JavaScript running on your page — including malicious scripts injected through a third-party library, a compromised CDN, or a user input vulnerability — has full, unrestricted access to localStorage.
Here's how trivial a token theft attack looks:
This is not a theoretical attack. OWASP consistently lists XSS as one of the top web vulnerabilities, and localStorage being accessible from JS makes it a prime target. The OWASP XSS Prevention Cheat Sheet specifically warns against storing sensitive data in localStorage.
So What Should You Use Instead?
There are three main alternatives, each with different trade-offs. Let me walk through all of them.
1. httpOnly Cookies (The Gold Standard)
This is what I recommend for most production apps. When your server sets a cookie with the HttpOnly flag, JavaScript cannot read it at all. The browser handles it transparently — sends it with every request, but keeps it hidden from your JS code.
On the client side, you don't need to do anything special with the token — the browser attaches it automatically:
Let's break down the three critical cookie flags:
- HttpOnly — Blocks JavaScript access entirely. An XSS attack cannot steal what it cannot read.
- Secure — Cookie only sent over HTTPS. Prevents interception on unencrypted connections.
- SameSite=Strict — Prevents the cookie from being sent in cross-site requests, blocking CSRF attacks. Use
Laxif you need cross-site GET requests (e.g., OAuth redirects), but understand the trade-off.
2. sessionStorage — A Slight Improvement, Not a Solution
I see developers switch from localStorage to sessionStorage thinking they've solved the problem. They haven't. sessionStorage is still fully accessible to JavaScript. The only difference is it gets cleared when the browser tab is closed.
sessionStorage limits the token's lifetime to the browser tab, which reduces the window of exposure, but it doesn't eliminate the fundamental XSS vulnerability. It's a marginal improvement at best.
3. In-Memory Tokens — The SPA Alternative
For SPAs (Single Page Applications) where you control the full client-side state, storing tokens as JavaScript variables in memory is a solid option. The token never touches storage APIs.
The trade-off: the token is lost on page refresh. The typical pattern to handle this is to use in-memory storage for short-lived access tokens, paired with a long-lived refresh token stored in an httpOnly cookie. The refresh token is used to silently re-acquire a new access token when needed.
Quick Comparison
- localStorage — Persists across sessions. Fully accessible to JS. ❌ Vulnerable to XSS.
- sessionStorage — Clears on tab close. Still accessible to JS. ⚠️ Slightly better than localStorage, still vulnerable.
- In-memory — Lost on refresh. Not in storage APIs. ✅ Good for access tokens in SPAs with refresh token pattern.
- httpOnly Cookie — Configurable persistence. Not accessible to JS. ✅ Best protection against XSS. Requires CSRF mitigation.
My Practical Recommendation
For most full-stack apps I build: use httpOnly cookies for your refresh token, and in-memory storage for short-lived access tokens. This gives you the security of httpOnly cookies while keeping your API calls fast (no DB lookup needed for access token validation).
If you're building a traditional server-rendered app (Next.js with SSR, Remix, etc.) — httpOnly session cookies are the clear winner. Libraries like iron-session or NextAuth.js handle all of this for you with solid defaults.
Want to go deep on cookie attributes? The MDN Set-Cookie documentation is comprehensive and keeps up-to-date with browser support.
Migrating an Existing App
If you're migrating an existing app away from localStorage tokens, here's the process I follow:
- Update your login endpoint to set an httpOnly cookie in addition to (or instead of) returning the token in the response body.
- Add a CSRF token mechanism if you're using cookies (a double-submit cookie pattern works well with SPAs).
- Update your API middleware to validate the cookie-based token instead of the Authorization header.
- Remove localStorage.setItem/getItem calls from your client-side auth code.
- Add credentials: 'include' to your fetch calls so cookies are sent cross-origin (if needed).
The Bottom Line
localStorage is convenient. That's what makes it dangerous. It's a developer experience optimization that comes at the cost of your users' security.
If you're building anything where real users authenticate — and someone stealing their session would be bad — use httpOnly cookies. The extra server-side setup is worth it.
If you're curious about other performance and security patterns I use in real projects, check out my projects page. And if you missed my post on why MongoDB indexing might be slowing down your app, that's worth a read too — same philosophy: understand the tool deeply before trusting it.