jwtsecurityxsscsrfcookiesauth

Where to Store JWT Tokens: localStorage vs Cookies vs Memory

·9 min read·Security & Auth

The question every frontend eventually asks

You've got a JWT access token from your auth server. Where do you put it in the browser? This looks like a small implementation detail, but it directly sets your exposure to the two biggest client-side attacks: XSS (malicious JavaScript running in your page) and CSRF (a forged cross-site request riding the user's session). There is no storage location that's immune to both; the goal is to minimize what an attacker gains from each.

Option 1: localStorage — convenient and risky

localStorage (and sessionStorage) is the default many tutorials reach for: localStorage.setItem("token", jwt), read it back, attach it as an Authorization: Bearer header. It's simple and immune to CSRF, because the browser never attaches it automatically — your code has to.

The problem is XSS. Anything in localStorage is readable by *any* JavaScript running on your origin — including a script injected through a compromised dependency, a vulnerable ad, or an unescaped user string. One XSS bug and the attacker reads your token and exfiltrates it. Because the token is a bearer credential, they can now impersonate the user from anywhere until it expires. For long-lived tokens, that's a serious blast radius.

Option 2: HttpOnly cookies — safer against XSS, exposed to CSRF

Store the token in a cookie flagged HttpOnly, and JavaScript can't read it at all — document.cookie won't show it. That defeats the "XSS steals the token" scenario, which is why cookies are generally safer than localStorage for credentials.

But cookies are attached automatically by the browser on every request to your domain, which reintroduces CSRF: a malicious page can trigger a request to your API and the browser will helpfully include the cookie. You mitigate this with:

  • `SameSite=Strict` (or Lax) so the cookie isn't sent on cross-site requests.
  • `Secure` so it's only sent over HTTPS.
  • A CSRF token pattern for any state-changing request if you support cross-site flows.

Cookies also have a size limit (~4KB) and are sent on every request, which matters for large tokens.

Option 3: in-memory — smallest blast radius

Keep the access token in a JavaScript variable (a module-level variable, a closure, a store) and *nowhere* persistent. It vanishes on refresh, and it's never in localStorage or a readable cookie, so a page reload or a new tab doesn't carry it and there's no persistent store for XSS to scrape at rest.

The catch: it doesn't survive a refresh, so on its own it logs the user out every time they reload. That's where the hybrid comes in.

The 2026 consensus: in-memory access token + HttpOnly refresh cookie

The pattern most security-conscious teams converge on splits the two tokens by risk:

  • Access token → in memory. Short-lived (minutes), attached as a Bearer header from a variable. If XSS runs, it can only grab a token that expires soon, and can't read it from any persistent store.
  • Refresh token → HttpOnly, Secure, SameSite cookie. Long-lived, unreadable by JavaScript, sent only to the refresh endpoint. On page load (or when the access token expires) the app calls /refresh; the browser sends the cookie, the server returns a fresh access token into memory.
text
Page load ──▶ POST /refresh  (HttpOnly refresh cookie sent automatically)
          ◀── { "accessToken": "..." }   ── held in a JS variable only
API call  ──▶ Authorization: Bearer <in-memory access token>

This gives you the best of each: XSS can't read the long-lived credential (it's HttpOnly), CSRF is contained (SameSite + the refresh endpoint is the only cookie consumer), and a stolen access token is worthless within minutes.

Rules that matter more than the storage choice

  • Keep access tokens short-lived. Every storage trade-off gets smaller when the token expires in five minutes.
  • Fix XSS at the source. No storage choice saves you if attackers can run arbitrary JS — escape output, set a Content-Security-Policy, and audit dependencies. Storage only limits the *damage*.
  • Never put secrets in the token. The payload is readable regardless of where you store it — verify with the JWT decoder.
  • Rotate refresh tokens on each use so a stolen refresh token has a short useful life.

For the attacks these choices defend against, see JWT vulnerabilities; for token issuance see OAuth token responses and JWT tokens explained.

Frequently asked questions

It's convenient but risky. Anything in localStorage is readable by any JavaScript on your origin, so a single XSS vulnerability lets an attacker steal the token. It's acceptable only for short-lived, low-privilege tokens where you've tightly controlled XSS; for sensitive sessions, prefer an in-memory access token with an HttpOnly refresh cookie.

No. HttpOnly cookies can't be read by JavaScript, which defeats XSS token theft, but because browsers send them automatically they're exposed to CSRF. Add SameSite, Secure, and CSRF protections to close that gap.

The common 2026 pattern is a short-lived access token held only in a JavaScript variable (in memory) plus a long-lived refresh token in an HttpOnly, Secure, SameSite cookie. XSS can't read the refresh token, CSRF is contained, and a leaked access token expires quickly.

In-memory storage doesn't survive a page refresh or a new tab, so the user would be logged out constantly. Pairing it with an HttpOnly refresh cookie restores persistence without exposing the long-lived credential to JavaScript.

Storage limits the damage, not the root cause. If attackers can run arbitrary JavaScript, they can make authenticated requests regardless of where the token lives. Storage choices reduce the blast radius; fixing XSS removes the threat.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.