Where to Store JWTs in the Browser: localStorage vs Cookies vs Memory (XSS and CSRF)
Search "where to store JWTs" and you get two camps yelling past each other. One says localStorage is fine and cookies are legacy nonsense. The other says localStorage is a security disaster and you must use HttpOnly cookies. Both are selling you a single winner, and there isn't one.
The property that makes a cookie resistant to token theft is the same property that opens you to CSRF. You can't pick a storage location that escapes both XSS and CSRF. You can only decide which attack you'd rather defend against, and how.
This guide walks the three options on their actual mechanics, explains why the in-memory access token plus HttpOnly refresh cookie pattern is a sensible default rather than a magic bullet, and gives you the honest caveats. First, one fact that shapes everything: a JWT is signed, not encrypted. Paste any token into the JWT Decoder and you'll read every claim in plaintext. That is exactly what an attacker reads too.
A JWT is readable by anyone holding it
Before storage, internalize what you're storing. A JSON Web Token is three base64url segments: header, payload, signature. The signature proves the token wasn't tampered with. It does not hide anything. Anyone who has the token can decode the payload and read every claim, including sub, email, roles, and whatever else you stuffed in there.
Drop a token into the JWT Decoder. The header and payload render as plain JSON instantly. It runs entirely in your browser, so the token never hits a server, but the point stands: no secret is needed to read the contents. If an attacker exfiltrates a token from localStorage, this is precisely what they see, and then they replay it against your API.
Two practical consequences. First, never put anything sensitive in the payload you wouldn't paste into a public form. Second, the entire storage debate is really about one question: how hard is it for an attacker to get their hands on this readable, replayable string?
localStorage: convenient, and a direct XSS target
localStorage is where most tutorials land because it's trivial. You call localStorage.setItem('token', jwt), read it back when you need to set an Authorization: Bearer header, done. It survives page reloads and tab closes, works cleanly across same-site and cross-site API setups, and sidesteps CSRF entirely because nothing is auto-attached to requests. A cross-origin page can't read your localStorage or forge your Authorization header, so classic CSRF doesn't apply.
The problem is XSS. localStorage is plain JavaScript-accessible storage. Any script that runs on your origin can read it: localStorage.getItem('token') and ship it off. That script doesn't have to be yours. It can ride in on a compromised npm dependency, a third-party analytics tag, an ad, or a single unescaped user input that renders as markup.
This is the asymmetry people miss. CSRF requires tricking a user into firing a request. An XSS payload, once it executes, can read the token, exfiltrate it, AND fire authenticated requests directly, all in one shot. localStorage gives that payload a clean, persistent credential to steal. That's why the security crowd flinches at it.
Cookies: HttpOnly stops theft, not abuse
The cookie pitch is the HttpOnly flag. Set it and JavaScript cannot read the cookie at all. document.cookie won't show it. So an XSS payload can't scrape the token value and exfiltrate it for offline or later use. Add Secure (HTTPS only) and SameSite and you've hardened it further.
Now the caveat: HttpOnly does not stop XSS from abusing the session. The browser auto-attaches that cookie to every same-origin request. So an injected script can't read the cookie, but it can still call your API, and the browser dutifully attaches the credential. The attacker rides the live session while their code runs in the page. What HttpOnly actually buys you is narrower than "XSS-safe": it bounds exfiltration of the credential, so the attacker can't lift the token and reuse it from their own machine after the tab closes. That's valuable, but it is blast-radius reduction, not immunity.
And the auto-attach behavior that resists exfiltration is the exact mechanism that creates CSRF. Because the browser sends the cookie on cross-site requests too, a malicious page can trigger a state-changing request to your API and the cookie comes along for the ride.
The XSS-vs-CSRF symmetry
Line the two patterns up and the tradeoff is structural, not a matter of taste:
| Approach | XSS exfiltration | CSRF exposure |
|---|---|---|
Token in localStorage / memory, sent via Authorization header | Exposed (JS can read it) | Immune (nothing auto-attached; cross-site page can't set your header) |
| Token in HttpOnly cookie, auto-attached | Resists theft (JS can't read it) | Exposed (browser auto-sends cross-site) |
Read it across and the picture is symmetric. The header approach is immune to CSRF precisely because the token sits in JS-reachable storage that no other site can touch and no browser auto-sends, which is the same reachability that exposes it to XSS theft. The cookie approach resists XSS theft precisely because the browser handles it opaquely and auto-attaches it, which is the same auto-attach that exposes it to CSRF.
There is no storage location that is both unreadable by your own page's JavaScript and not auto-attached by the browser. You pick which side to defend, then you defend it. That choice is the real content of "where do I store my JWT."
The pattern that splits the difference
The widely-recommended default doesn't pick one storage location. It splits the two tokens by their job:
- Access token: in memory. Hold it in a JavaScript variable or closure, used to set the
Authorizationheader. Not in localStorage, not in a cookie. It dies on page refresh, which is the point. - Refresh token: HttpOnly, Secure, SameSite cookie. Scoped tightly to your token-refresh endpoint. JavaScript never touches it. On load or when the access token expires, you call
/refresh; the browser attaches the cookie, the server mints a new short-lived access token, you hold it in memory again.
Why this is reasonable: the access token is the thing scripts constantly reach for, so keeping it in memory means there's no persistent copy on disk for a passive scrape to find, and it's gone when the tab closes. The long-lived refresh token, the credential you'd most hate to lose, is the one an XSS payload genuinely cannot read, because it's HttpOnly.
Be precise about what "in memory" buys you, though. It is not XSS-safe. Any script on the page can read a JS variable, hook fetch to skim the header, or just call /refresh itself and get a fresh access token. What memory actually gives you: no disk-backed persistence, no passive scrape surface, and a short window of exposure. The real win is on the refresh token, the one credential the injected script can't exfiltrate. The pattern bounds the blast radius to "while the tab is open and the payload runs," not to zero.
Same-site vs cross-site changes the math
Are your frontend and your API on the same site or not? This should shape your decision more than any blog opinion.
Same site (e.g. app.example.com calling api.example.com, or a same-origin API): cookies are easy. SameSite=Lax kills most cross-site sends while leaving your own app's requests working. Set it explicitly, though: Lax is the default only in Chromium browsers (Chrome, Edge, Opera). Firefox and Safari do not enforce a default SameSite value, so a cookie with no attribute behaves differently across browsers. The refresh cookie pattern slots in cleanly.
Cross site (frontend on one registrable domain, API on another): the cookie must be SameSite=None; Secure to be sent at all, which switches CSRF protection back on as your responsibility and leans you entirely on anti-CSRF tokens. This is also where some browsers' third-party-cookie restrictions start biting. In a genuinely cross-site split, the cookie approach gets fiddly, and a header-based token (accepting the XSS tradeoff, mitigated hard) sometimes wins on sheer operability.
On SameSite itself: Lax is a strong setting but not complete. Strict is tighter but breaks the "click a link in your email and you're logged in" flow because the cookie won't ride that top-level navigation. Neither Lax nor Strict covers same-site attacks or a compromised sibling subdomain. For state-changing requests, pair the cookie with a real anti-CSRF token (double-submit or synchronizer pattern) as defense in depth. SameSite is a layer, not the whole wall.
Keep access tokens short, and verify it
Every part of the in-memory pattern leans on one assumption: the access token is short-lived. If an XSS payload skims it from memory but it expires in a few minutes, the stolen credential is close to worthless on its own; the attacker has to keep the payload running to stay live. If your "short-lived" access token is actually valid for hours, the in-memory story collapses, because a single skim buys a long replay window.
So check. Paste your real access token into the JWT Decoder and read iat and exp. The gap between them is your actual exposure window. The decoder also flags common signing problems while you're in there, like an alg: none token that skips verification entirely, or an HS256 token signed with a guessable secret, both of which let an attacker forge tokens outright regardless of where you store them.
Two more things the decode confirms: that you're not leaking sensitive claims in a readable payload, and that the exp is being honored at all. Storage strategy is downstream of these. A perfectly-stored token with a weak secret or a 24-hour lifetime is still a liability.
The option the three-way framing omits, and the real fix
"localStorage vs cookies vs memory" quietly assumes the browser must hold a JWT at all. It doesn't have to. The backend-for-frontend (BFF) pattern moves tokens off the browser entirely: the browser holds only an ordinary HttpOnly session cookie, and a thin server-side layer keeps the real access and refresh tokens and attaches them to upstream API calls. The browser never sees a JWT, so there's nothing for XSS to steal. The cost is operational, you now run and secure that server layer, but for high-value apps it's often the right call. If "where do I store the JWT" keeps feeling like choosing the least-bad option, the honest answer may be: don't store it client-side at all.
Whichever you pick, the conclusion that matters: the real defense against XSS is not storing the token cleverly, it's not having XSS. Content Security Policy, your framework's automatic output escaping, Trusted Types, and disciplined dependency hygiene prevent the script from running in the first place. Storage choice only decides how much damage a successful XSS does. Treat it as blast-radius management layered on top of prevention, not as the prevention itself.
Skip the manual work. The companion tool runs this in your browser, with nothing uploaded.
JWT DecoderFrequently asked questions
Is it safe to store a JWT in localStorage?
It works and it's CSRF-immune, but it's directly exposed to XSS. Any script that runs on your origin (including a compromised dependency or third-party tag) can read localStorage and exfiltrate the token. For low-risk apps with strong XSS defenses it can be acceptable; for anything sensitive, prefer keeping the access token in memory and the refresh token in an HttpOnly cookie.
Does an HttpOnly cookie protect my JWT from XSS?
Partially. HttpOnly stops JavaScript from reading the cookie, so an XSS payload can't exfiltrate the token to reuse elsewhere. It does not stop XSS from abusing the live session, because the browser still auto-attaches the cookie to requests the injected script fires. HttpOnly reduces blast radius (no credential theft); it does not make you XSS-safe.
Why can't I just pick a storage option that avoids both XSS and CSRF?
Because the tradeoff is structural. A token in localStorage or memory sent via the Authorization header is CSRF-immune but XSS-readable. A token in an auto-attached cookie resists XSS theft but is CSRF-exposed, since the browser sends it cross-site. The same property that defends one attack enables the other. You mitigate the side you didn't pick (CSP for XSS, anti-CSRF tokens and SameSite for CSRF).
Is keeping the access token in memory actually XSS-safe?
No. Any script on the page can read a JavaScript variable, hook fetch to skim the Authorization header, or call your refresh endpoint itself. Memory buys you no disk persistence, no passive scrape surface, and a short exposure window. The genuine protection in the pattern is the HttpOnly refresh cookie, which an XSS payload cannot read.
How do I check whether my access token is short-lived enough?
Decode it and compare the iat and exp claims; the gap is your real replay window if the token is stolen. Paste it into the JWT Decoder at codeswap.net/crypto/jwt-decoder/, which renders the payload client-side and shows both timestamps. Tokens often turn out valid far longer than intended because a default was never changed.
Does it matter if my frontend and API are on different domains?
Yes, significantly. Same-site setups let cookies use SameSite=Lax with little friction (set it explicitly, since only Chromium browsers default to Lax). A cross-site split forces SameSite=None; Secure, turns CSRF protection back into your job, and runs into third-party-cookie restrictions, which sometimes makes a header-based token (with hard XSS mitigation) more practical. Decide storage with your deployment topology in front of you.