Where to store JWTs: localStorage vs cookies
Short answer: store JWTs in httpOnly cookies if your architecture allows it, because JavaScript cannot read them and that removes the most damaging token theft path. localStorage is acceptable only when you understand that any XSS on your site hands the token to the attacker. The common middle ground for single page apps is a short lived access token in memory plus a refresh token in an httpOnly cookie.
That is the conclusion. The rest of this post is why, because the reasoning is what lets you adapt the advice to your actual app. If you want to see what is inside a token while you think about it, the JWT Decoder decodes headers and claims entirely in your browser, and our JWT anatomy post walks through the structure.
The three options
After a user authenticates, your frontend receives a token and has to keep it somewhere across requests and page loads:
- localStorage (or sessionStorage): read and attach it to requests yourself, typically in an
Authorization: Bearerheader. - An httpOnly cookie: the server sets it; the browser attaches it automatically; JavaScript cannot touch it.
- In memory only: a variable in your app. Gone on refresh, so it needs a companion mechanism to restore sessions.
Each choice trades one attack surface for another, so the real question is which attack you are better equipped to defend.
What can XSS do to each option?
Cross site scripting means an attacker got their JavaScript running on your page, through an unescaped comment field, a compromised npm package, a third party script tag.
With the token in localStorage, the attacker's script reads it in one line and ships it to their server. They now hold a valid token they can use from anywhere, for as long as it lives, even after the victim closes the browser. Token theft turns a transient foothold into a durable, portable session.
With the token in an httpOnly cookie, the script cannot read it. This is the entire point of the httpOnly flag. The attacker can still make requests from the victim's browser and the cookie will ride along, so XSS is still very bad. But there is a real difference in blast radius: the attacker can act only while their script runs in a victim's open tab, cannot exfiltrate the credential itself, and loses access when the page closes. Stolen tokens from localStorage keep working from the attacker's own machine.
So the honest comparison is not "cookies are XSS proof". It is: under XSS, localStorage loses the credential; cookies lose only the ability to act during the attack.
What cookies cost you: CSRF
Because the browser attaches cookies automatically, a malicious site can trigger requests to your API with the victim's cookie attached. That is cross site request forgery, and it is the classic argument against cookies.
It is also, today, the more solvable problem:
- SameSite. Modern browsers default cookies to
SameSite=Lax, which blocks the cookie on most cross site requests. Set it explicitly:SameSite=LaxorStrict. - CSRF tokens. A per session secret the frontend must echo back, which a foreign site cannot read.
- Origin checks. Your API rejects state changing requests whose
Originheader is not yours.
CSRF defenses are mechanical and verifiable. XSS prevention, by contrast, requires that every escape site, every dependency, and every third party script on every page stays correct forever. Betting your tokens on "we will never have XSS" is a bet many teams have lost.
The pattern most SPAs should use
For a single page app with its own backend, the widely recommended arrangement:
- Access token: in memory. Short lived, five to fifteen minutes. Never written to storage. XSS can grab it from memory while running, but it expires quickly and does not survive the tab.
- Refresh token: httpOnly, Secure, SameSite cookie, scoped with a
Pathto the refresh endpoint only. JavaScript cannot read it; a stolen page cannot exfiltrate it. - On page load, the app calls the refresh endpoint; the cookie authenticates the request and a fresh access token comes back into memory.
- Refresh token rotation: each use issues a new refresh token and invalidates the old one, so a replayed old token reveals theft and kills the session.
This confines the durable credential behind httpOnly, keeps the frequently used credential short lived, and gives you server side revocation at the refresh boundary.
If your app is served by the same backend it talks to, you can go simpler: put the whole session in an httpOnly cookie and skip bearer headers entirely.
When is localStorage a defensible choice?
- The token is genuinely short lived and low privilege
- Your API is on a different domain where cookies would require third party cookie gymnastics that browsers increasingly block
- Native or non browser clients share the same token flow
If you do this, treat XSS as a token compromise event, keep lifetimes short, and support server side revocation.
Practical checklist
| Decision | Recommendation |
|---|---|
| Same site backend | httpOnly session cookie, done |
| SPA + API | Access token in memory, refresh token in httpOnly cookie |
| Cookie flags | HttpOnly; Secure; SameSite=Lax minimum |
| Access token lifetime | Minutes, not days |
| Refresh tokens | Rotate on use, revoke on anomaly |
| sessionStorage | Same XSS exposure as localStorage, only scoped to the tab |
One more thing worth internalizing: a JWT is not encrypted. Anyone holding it can read every claim inside, which you can verify yourself by pasting one into the JWT Decoder. It runs fully client side and your token never leaves the browser, but the lesson stands: never put secrets in claims, and treat possession of the token as possession of the session.