🗝️

Where to store a JWT: localStorage vs. cookies

localStorage is easy but XSS-exposed; cookies are safer but bring CSRF. A practical guide to storing JWTs on the client and the pattern most teams land on.

· 7min read

Once your backend issues a JWT, the client has to put it somewhere so it can attach it to the next request. This looks like a trivial decision - it isn't. Where you store the token decides which attacks you're exposed to, and getting it wrong is one of the most common security holes in single-page apps.

There are two realistic homes for a token in the browser: localStorage and cookies. Each closes one attack vector and opens another.

Option 1: localStorage

This is the path every tutorial takes because it's the easiest. After login you save the token and read it back whenever you need to call your API:

localStorage.setItem('token', jwt)
// later
fetch('/api/me', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })

It works everywhere, survives page reloads, and there's no CSRF risk because you attach the token manually - the browser never sends it automatically.

The catch: localStorage is readable by any JavaScript running on your page. If an attacker lands a single cross-site scripting (XSS) payload - a compromised npm dependency, a bad ad, an unescaped comment - they can run localStorage.getItem('token') and exfiltrate an active credential in one line. There is no browser protection against this.

Option 2: cookies

The alternative is to have the server set the token as a cookie, ideally with the HttpOnly flag:

Set-Cookie: token=eyJ...; HttpOnly; Secure; SameSite=Strict

HttpOnly means JavaScript cannot read the cookie - document.cookie won't show it. That neutralizes the XSS token-theft problem above: even with script execution, an attacker can't lift the token out.

The catch: cookies are sent automatically by the browser, which reintroduces CSRF - a malicious site can trigger a request to your API and the browser will helpfully attach the cookie. The fix is the SameSite attribute (Strict or Lax), which stops the cookie being sent on cross-site requests, plus CSRF tokens for anything sensitive.

Side by side

localStorageHttpOnly cookie
Readable by JavaScriptYes (XSS-exposed)No
Sent automaticallyNoYes (CSRF-exposed)
Main defense neededPrevent all XSSSameSite + CSRF tokens
Works across subdomainsManualDomain attribute
Good forPure API / mobile-style SPAsServer-assisted web apps

Notice the symmetry: localStorage trades CSRF-safety for XSS-exposure; cookies do the exact opposite.

The pattern most teams land on

Serious apps usually stop treating this as "one token in one place" and split it:

  • A short-lived access token (5-15 minutes) kept in memory - a JavaScript variable, not localStorage. If the tab closes it's gone, and it's never written to disk for XSS to steal.
  • A long-lived refresh token stored in an HttpOnly, Secure, SameSite cookie, used only to mint new access tokens.

This gives you the best of both: no persistent token sitting in localStorage, and the one credential that does persist is invisible to JavaScript. It pairs naturally with a refresh flow - see handling JWT expiration and token refresh.

What not to do

  • Don't put long-lived tokens in localStorage. A token valid for hours or days, readable by any script, is the worst combination. If you must use localStorage, keep the expiry short.
  • sessionStorage isn't a security upgrade. It's still readable by JavaScript; it just clears when the tab closes.
  • Don't store anything secret in the payload. However you store it, a JWT is only encoded, not encrypted. Anyone who gets it can read every claim - decode one in the JWT decoder to see exactly what's exposed.

Know how long a stolen token stays useful

Storage strategy and token lifetime work together. A leaked token is dangerous until its exp claim passes, so the shorter the expiry, the smaller the blast radius of any theft. Paste a token into the browser-based JWT decoder to read its exp - it's converted to a readable date and flagged if already expired, so you can see at a glance how long a compromised token would actually grant access.

The honest summary

There's no storage location that's simply "secure." localStorage is convenient and XSS-exposed; HttpOnly cookies are safer against token theft but bring CSRF back into scope. For anything beyond a toy project, keep a short-lived access token in memory and a refresh token in an HttpOnly cookie, and keep expiry times tight. Still deciding whether to use JWTs at all? Compare them with session cookies first.