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.
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
| localStorage | HttpOnly cookie | |
|---|---|---|
| Readable by JavaScript | Yes (XSS-exposed) | No |
| Sent automatically | No | Yes (CSRF-exposed) |
| Main defense needed | Prevent all XSS | SameSite + CSRF tokens |
| Works across subdomains | Manual | Domain attribute |
| Good for | Pure API / mobile-style SPAs | Server-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,SameSitecookie, 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 uselocalStorage, keep the expiry short. sessionStorageisn'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.