Where to store a JWT
localStorage is readable by every script on the page. An httpOnly cookie is readable by none — but it is sent automatically, which is the whole of the trade.
By the Withuse team · Updated
The two failure modes
| localStorage | httpOnly cookie | |
|---|---|---|
| Readable by script | ❌ yes — any script | ✅ no |
| Sent automatically | ✅ no | ❌ yes |
| Vulnerable to XSS | token is stolen | requests can be made |
| Vulnerable to CSRF | no | yes — needs mitigation |
| Survives refresh | ✅ | ✅ |
| Works cross-origin | ✅ you set the header | ⚠️ needs CORS credentials |
Neither row is a verdict on its own. What matters is which failure you can recover from.
Why XSS is the worse of the two
With a token in localStorage, any script running in your origin can read it — and that is not only code you wrote. It is every dependency in your bundle, every transitive package those dependencies pull, and every analytics or support widget someone adds to the page later. One compromised package exfiltrates the token in a single line, and from that point an attacker holds a credential that works from anywhere, on their machine, for as long as it remains valid.
With an httpOnly cookie, the same XSS flaw is still serious: the attacker can make requests from the victim's browser while the page is open. But they cannot take the credential away. The blast radius is bounded by the session rather than by the token's expiry, and that difference is why the cookie is the better default.
This is also why short expiry matters more for a token you cannot revoke: with no way to withdraw it, the lifetimeis the mitigation.
The price of cookies: CSRF
Cookies are attached to requests by the browser without your code being involved. That convenience is the vulnerability — a form on another site can trigger a request to yours, and the credential rides along, so the request arrives authenticated although the user never intended it.
SameSite removes most of this and is now the default. With Lax, cookies are not sent on cross-site POST requests but still accompany top-level navigation; with Strict, not even that. The remaining gap is worth naming: subdomains are not cross-site. A vulnerability on any host under your registrable domain sits inside the boundary SameSite draws.
Set-Cookie: token=...; HttpOnly; Secure; SameSite=Lax; Path=/
For anything sensitive, keep an anti-CSRF token alongside rather than treating SameSite as the only control.
Memory only, and the pattern that actually works
A token held in a JavaScript variable disappears on refresh and is invisible to other tabs. It is still readable by script in the page — memory offers no protection against XSS that httpOnly does — but it removes persistence, so an attacker must exfiltrate during the session rather than reading a value that sits there indefinitely.
The shape that gets the most from this is a short-lived access token in memory paired with a refresh token in an httpOnly cookie. The access token never touches storage; the refresh token restores the session on reload and can be revoked server-side, which is the property a bare JWT lacks entirely.
Choosing
Use an httpOnly, Secure, SameSite cookie with CSRF protection for anything that is a real session. Reserve localStorage for cases where a cookie genuinely cannot work — a mobile app, or a client on a different origin from the API where cookie handling becomes its own project — and keep the token short-lived when you do.
Frequently asked questions
Is localStorage safe for a JWT?
Only if no script on your page can ever be hostile, which is a stronger assumption than most sites can make. localStorage is readable by any JavaScript running in the origin, including every dependency you install and every third-party tag someone adds to the page later. A single cross-site scripting hole or a compromised npm package exfiltrates the token in one line. An httpOnly cookie cannot be read by script at all, so the same flaw lets an attacker make requests as the user but not walk away with a credential that works from anywhere for as long as it lives, on a machine you have no visibility into and cannot revoke access from.
Why is an httpOnly cookie not simply the answer?
Because cookies are attached to requests automatically, which is what makes them vulnerable to cross-site request forgery. A form on another site can trigger a request to yours and the browser will send the cookie along, so the request arrives authenticated even though the user never intended it. localStorage has the opposite property: nothing is attached automatically, your code must add the Authorization header, and a cross-site request therefore carries no credential. You are choosing which attack to defend against explicitly, and the cookie route means you must also handle CSRF, which is a solved problem with a well-understood mitigation, unlike a credential that has already left the building. Framework defaults now include CSRF protection, so the cost is mostly configuration rather than code.
Does SameSite fix CSRF?
It removes most of it, which is why the cookie route is more attractive than it used to be. SameSite=Lax, now the default in major browsers when the attribute is absent, stops cookies being sent on cross-site POST requests while still allowing top-level navigation, and SameSite=Strict blocks even that. What remains is the gap: same-site subdomains are not cross-site, so a vulnerability on any subdomain of your registrable domain can still reach you. For anything sensitive, keep an anti-CSRF token as well rather than relying on SameSite as the only control, since a single vulnerable subdomain sits inside the boundary it draws and is therefore treated as same-site. Treat it as defence in depth, not as the whole defence.
What about storing the token in memory only?
It is the most defensible option and the least convenient. A token held in a JavaScript variable is gone on refresh and unreachable from another tab, so it survives neither, and it is still readable by script running in the page — memory does not protect against XSS the way httpOnly does. What it does remove is persistence: an attacker must exfiltrate during the session rather than reading a value that sits there indefinitely. The usual shape is an in-memory access token paired with a refresh token in an httpOnly cookie, which restores the session on reload and can be revoked server-side when the access token cannot, which is the property that makes the pairing worth the complexity.
So which should I choose?
An httpOnly, Secure, SameSite cookie for anything that is a real session, with CSRF protection alongside it. That combination removes the failure mode you cannot recover from — a stolen long-lived credential — and leaves one you can mitigate with a token check. Reserve localStorage for cases where a cookie genuinely will not work, most often a mobile app or a client on a different origin from the API, and keep the token short-lived when you do. The decision is not about which is safer in the abstract but about which failure you can survive — a stolen long-lived credential is the one you cannot, because nothing you do afterwards takes it back.
References
Inspect a token's claims and expiry with the JWT decoder. More tools at withuse.io/tools.