← Blog
October 16, 2026 · 9 min

How to Store JWTs: localStorage vs httpOnly Cookies

The most-asked JWT question, answered: where tokens should live in the browser, what XSS and CSRF each option really means, and the hybrid pattern most teams ship.

The question everyone asks

"I have a JWT — where do I put it in the browser?" The honest answer: there is no perfectly safe place to store a token in a browser. Each option trades one risk for another. Here's the full picture so you can pick the trade-off that fits your app.

Option 1: localStorage

localStorage.setItem("token", jwt) is the pattern in most tutorials.

Pros: trivially simple, survives tabs and restarts, no server coordination needed, immune to CSRF (the browser never attaches it automatically — your code sends it explicitly).

Cons: any JavaScript running on your page can read it. That includes your own code, your dependencies, and anything an XSS vulnerability lets an attacker inject. A single compromised npm package or unescaped user input and the token is gone — silently, with no server-side way to know.

When it's acceptable: apps with no untrusted user-generated HTML, a strict Content-Security-Policy, audited dependencies, and short-lived access tokens. Even then, never put a long-lived refresh token there.

Option 2: sessionStorage

Same API, but scoped to the tab and cleared when it closes.

Pros: slightly smaller blast radius — closing the tab destroys the token.

Cons: just as readable by XSS while the tab is open, which is when an attack actually happens. Users with multiple tabs trigger repeated logins. Treat it as localStorage with marginally better hygiene, not a security upgrade.

Option 3: httpOnly cookies

The server sets the token with Set-Cookie: token=...; HttpOnly; Secure; SameSite=Strict; Path=/.

Pros: JavaScript cannot read an httpOnly cookie — XSS can ride along with requests but cannot exfiltrate the token itself. The browser sends it automatically on same-site requests.

Cons: automatic attachment is exactly what CSRF exploits. You must pair cookies with SameSite (Strict or Lax) and ideally a CSRF token for state-changing requests. Cookies also add cross-domain friction for separate API domains and native mobile clients.

What actually happens in attacks

  • XSS + localStorage: attacker reads the token directly and can replay it from anywhere until it expires. With short access tokens (5–15 minutes) the replay window shrinks; with week-long tokens it's game over.
  • XSS + httpOnly cookie: attacker can't steal the token, but can still make requests *as the user* from the compromised page (session riding). The damage is real but contained to pages the attacker controls and to the browser session.
  • CSRF + cookie: attacker's page triggers a state-changing request with the victim's cookie attached. SameSite=Strict or Lax plus CSRF tokens close this.

The XSS comparison is why security teams generally prefer httpOnly cookies: it removes the exfiltration channel, the most damaging outcome.

The hybrid pattern most teams ship

1. Short-lived access token (5–15 minutes) kept in memory or httpOnly cookie. 2. Refresh token in an httpOnly, Secure, SameSite=Strict cookie scoped to the refresh endpoint, rotated on every use. 3. On app load, silently refresh — no token persisted in localStorage at all. 4. Server-side: pin the algorithm, verify the signature every request, and version refresh sessions so you can revoke.

This gives you XSS resistance, a small CSRF surface, instant-ish revocation through refresh sessions, and tokens that go stale in minutes even if something leaks.

Quick decision guide

  • Single server-rendered backend → session cookie, skip JWTs entirely.
  • SPA + same-domain API → httpOnly cookies + SameSite + CSRF token.
  • SPA + separate API domain → httpOnly cookies with a narrowly scoped CSRF setup, or in-memory access token + httpOnly refresh cookie.
  • Native mobile → platform secure storage (Keychain / Keystore), never web storage.

Whatever you choose, the token is only as trustworthy as the key that signs it. Validate your signing secret's strength with the JWT secret validator, decode live tokens while debugging with the JWT decoder, and generate a properly-sized replacement with the JWT secret generator.