Where Should I Store a JWT? localStorage, Cookies, and the XSS/CSRF Trade-off
Where to keep a JWT in the browser is usually asked as if one location were simply safer. It isn't a ranking. Each option answers two questions differently — can JavaScript read the token, and does the browser attach it automatically — and those map onto XSS and CSRF. This article works through localStorage, cookies, and in-memory storage, the patterns the industry has settled on, and why stopping XSS matters more than any storage choice.
The first two articles in this series were about the server: how to read a token, and how to verify one without being fooled. This one moves to the browser. Once your single-page app receives a JWT, it has to keep it somewhere, and the question of where turns out to be one of the most argued-about topics in web auth — usually for the wrong reason.
It’s argued as if the answer were a ranking, with one storage location that is simply “more secure” than the others. It isn’t. Every option is defined by how it answers two independent questions: can JavaScript running on your page read the token, and does the browser attach it to requests automatically. The first question is about cross-site scripting (XSS); the second is about cross-site request forgery (CSRF). Each storage choice answers them differently, which means the right answer depends on which attack you’re more exposed to — and, above everything else, on whether you can keep XSS out of your page at all. That last point is the one most of these debates bury, so we’ll build up to it deliberately.
Two different attackers
Before comparing storage, it’s worth separating the two threats cleanly, because they get conflated constantly.
XSS is when an attacker gets their JavaScript running inside your origin — through an unescaped piece of user input, a compromised third-party script, or a vulnerable dependency. Once that happens, their code runs with exactly the privileges your own code has. It can read anything your scripts can read and call any endpoint your app can call. XSS is code execution in your page.
CSRF is different and, in a way, more limited: the attacker’s own site causes the victim’s browser to fire an authenticated request at your API. It works because the browser automatically attaches ambient credentials — most often cookies — to requests for your domain, even when the request originates from evil.example. The attacker never sees the token and never sees the response; they simply cause an action to happen using credentials the browser volunteers on their behalf.
Keep that distinction in hand: XSS reads and acts from inside your page; CSRF forges a request from outside it and relies on the browser bringing the credentials along. The storage options line up almost entirely along these two axes.
The options, and what each one exposes
localStorage and sessionStorage. This is the default choice in most SPA tutorials, and it has one genuine strength and one fatal weakness. It is readable by any JavaScript on the page, so a single line — localStorage.getItem('token') — hands the token to an XSS payload, which can then ship it off to an attacker’s server to be replayed anywhere, anytime, until it expires. That is the fatal weakness. The genuine strength is the mirror image: because the value is only ever attached to requests by your own code (as an Authorization: Bearer header), the browser never sends it automatically, so a cross-site request can’t carry it. localStorage is, in other words, CSRF-immune and XSS-fatal. (sessionStorage differs only in lifetime — per-tab, cleared when the tab closes — not in its security profile.)
A regular, non-httpOnly cookie used to hold the token. As a place to keep the credential this is the worst of both worlds: JavaScript can read it and the browser sends it automatically, so you inherit the XSS exposure of localStorage and the CSRF exposure of cookies at once. (Non-httpOnly cookies are perfectly reasonable for other jobs — a language preference, non-sensitive UI state, a double-submit CSRF token — just not for holding the token itself.)
An httpOnly cookie. The httpOnly flag makes a cookie invisible to JavaScript, so document.cookie can’t see it and an XSS payload can’t read the token out to exfiltrate it. That is a real and valuable property. In exchange, the browser sends the cookie automatically on requests to its domain — subject to its Domain, Path, Secure, and SameSite scoping — which puts you squarely in CSRF territory and obliges you to add CSRF defenses (more on those below). But there’s a subtlety here that a lot of advice gets wrong, and it deserves its own paragraph.
httpOnly stops theft, not abuse
The httpOnly flag prevents an XSS payload from reading the token. It does not prevent that same payload from using it. An attacker running JavaScript in your page can simply make authenticated requests from the page itself — call your “change email” endpoint, mint an API key, transfer whatever your app can transfer — and the browser will attach the httpOnly cookie to each of those requests exactly as it would for legitimate code. The attacker can’t steal the token to replay it later or from another machine, but they can act as the user, right now, for as long as the page is open.
So httpOnly genuinely helps: it bounds the blast radius by stopping exfiltration, which means no long-lived stolen credential and no replay from the attacker’s own infrastructure. But it does not neutralize XSS. This is the crucial correction to the common belief that “httpOnly cookies make you XSS-safe.” They make you harder to rob, not immune to being impersonated while compromised.
In-memory storage — a plain JavaScript variable or a module-level closure — rounds out the set. Because nothing is written to disk, there’s no persistent copy to steal; when the tab reloads or closes, the token is gone. It isn’t auto-attached, so it’s CSRF-immune like localStorage, and it’s narrower than localStorage against theft in one respect: there’s no stored copy to exfiltrate and replay later. That’s a real but limited benefit — a closure variable may not even be directly reachable by injected code, yet an XSS in your origin can still call authenticated endpoints, hook into your request path, or capture a fresh token during the refresh flow. In-memory storage frustrates offline replay; it does not stop same-origin XSS from abusing the session. The cost is UX: the token vanishes on every refresh, so you need a way to silently obtain a fresh one — which is precisely the refresh-token machinery of the next article.
The truth the debate buries: XSS beats storage
Line those up and a pattern emerges that reframes the whole question. If an attacker can run JavaScript in your origin, no client-side storage location fully protects the token. In localStorage it’s stolen outright. In an httpOnly cookie it can’t be read, but the attacker rides it to act as the user anyway. In memory there may be no directly readable copy, but XSS can still ride the session — calling authenticated endpoints, or capturing a token as it’s refreshed. There is no arrangement of browser storage that is safe against an attacker already executing code on your page.
That leads to the honest hierarchy of controls, and it’s the opposite of how the question usually gets asked. Preventing XSS is the primary defense; the storage choice only decides how much worse a successful XSS gets. Anyone who tells you “put the token in X and XSS can’t hurt you” is overselling — storage is damage-limitation, not prevention. So the work that actually protects your users lives mostly outside the storage decision: rely on your framework’s automatic output escaping (React, Vue, and Angular all escape by default) and treat every dangerouslySetInnerHTML or v-html on untrusted data as a liability; ship a strict Content-Security-Policy, which both limits what injected script can do and restricts where it could exfiltrate a stolen token; consider Trusted Types to choke off DOM-based sinks; pin the third-party static assets you can version and hash with Subresource Integrity (it verifies a known file, so it’s a spot control, not a general supply-chain fix); and keep dependencies patched, since a single compromised package in your build is an XSS with your app’s full privileges. Storage choice is a real decision, but it’s the second one, not the first.
If you use cookies, deal with CSRF properly
Choosing an httpOnly cookie means taking on CSRF, so it’s worth knowing what actually defends against it rather than reaching for folklore.
The SameSite cookie attribute is the modern front line. SameSite=Lax, now the default in current browsers, keeps the cookie off cross-site subresource requests, and off unsafe top-level navigations — a cross-site form POST that navigates to your app won’t carry it — while still sending it on ordinary top-level GET navigations, so normal links and logins keep working. It’s a strong mitigation rather than an unconditional barrier: browsers ship compatibility carve-outs (notably a short “Lax-allowing-unsafe” window in which a freshly-set cookie is still sent on a top-level cross-site POST), so treat it as raising the bar, not sealing the door. Strict is tighter, but because it withholds the cookie even when a user arrives through a cross-site link, your app simply looks logged-out on that first navigation until a same-site request re-sends it. None doesn’t so much re-open CSRF as remove the SameSite layer entirely — it now requires Secure, and you’re back to relying on server-side CSRF defenses.
SameSite is necessary but not a complete answer. It offers no protection against a same-site attacker — a compromised or attacker-controlled subdomain is same-site to your cookie — it has historical edge cases, and older clients don’t honor it. So defense in depth still applies: a synchronizer or double-submit CSRF token, validating the Origin/Referer header on state-changing requests, or requiring a custom header that a cross-site form is unable to set (browsers won’t let a simple cross-origin form add arbitrary headers). Worth naming for contrast: the localStorage-plus-Authorization: Bearer approach is inherently CSRF-immune precisely because the browser never attaches that header on its own. That immunity is the one real security advantage of the localStorage pattern — paid for, as we’ve seen, with its XSS exposure.
Two common patterns
Two patterns have emerged as the pragmatic answers, and they’re worth understanding as a pair.
The first is a short-lived access token in memory, paired with an httpOnly, Secure, SameSite refresh cookie. The access token lives in a JavaScript variable — not persisted, so nothing survives a reload to be stolen later — and it’s sent as a Bearer header, so it’s CSRF-immune. Crucially, it’s short-lived. The refresh token, the long-lived credential you actually need to protect, lives in an httpOnly cookie scoped to the refresh endpoint, where JavaScript can’t read it. On load, and whenever the access token expires, the app calls the refresh endpoint to get a new one. This deliberately puts the long-lived credential where JavaScript can’t read it and keeps the reachable credential short-lived, so what an attacker can quietly exfiltrate and replay offline is only ever a token with minutes left on it. Be clear about what that does and doesn’t buy, though: an XSS running in your origin can still call the refresh endpoint itself, read the new access token out of the response, and keep the session alive — and it can call your business APIs directly as the user in the meantime. Short lifetimes shrink the value of a single stolen token replayed elsewhere; they do not shrink a live same-origin XSS down to a few minutes. The pattern’s real win is keeping the refresh token out of JavaScript’s reach, not making XSS harmless. It leans entirely on the lifecycle machinery — short lifetimes and rotating refresh tokens — that the final article covers.
The second — stronger specifically against bearer-token exfiltration and offline replay — is the Backend-for-Frontend (BFF) pattern, in which the browser never holds the JWT at all. The SPA talks only to its own backend, authenticated by an ordinary httpOnly session cookie; that backend holds the tokens and calls the downstream APIs on the user’s behalf. This is what the current OAuth guidance for browser-based apps recommends for higher-security systems, for exactly the reason this article has been building toward: it removes the bearer token from JavaScript’s reach entirely, so there’s nothing in the page to exfiltrate or replay elsewhere. It still isn’t an XSS cure — an attacker running in your origin can drive authenticated actions through the BFF using the same httpOnly session cookie the browser attaches for legitimate code. What BFF eliminates is token theft and offline replay, not same-origin abuse. The cost is architectural — you need that backend component, and it reintroduces some server-side session state (and its own CSRF handling for that cookie) — but it sidesteps the storage debate rather than trying to win it.
A reminder from the first article, and the tool to make it concrete
It’s easy to think of localStorage as a hiding place. It isn’t. A signed JWT — a JWS, the kind this series and the inspector deal with — has a Base64URL-encoded payload that is readable, not encrypted, as the first article laid out. (An encrypted JWE is the exception, and a rare one in browser auth.) So the token sitting in localStorage is a plainly readable string that decodes to plainly readable claims. Paste one into the JWT inspector and you’re looking at exactly what an XSS payload sees the instant it reads localStorage: the whole token, and every claim inside it, in the clear. (If you want to pull apart a single segment by hand, the Base64 encoder/decoder shows the same thing at a lower level.) That’s also the reason you never put anything sensitive in the payload — storage doesn’t conceal it and neither does the token.
Three myths worth retiring
“localStorage is fine as long as I sanitize my inputs.” Sanitization reduces XSS; it rarely eliminates it, and it only takes one missed sink or one compromised dependency. Betting the entire, replayable token on your app having zero XSS forever is a bet worth not making — which is the whole argument for keeping the long-lived credential out of JavaScript’s reach.
“httpOnly cookies make me XSS-safe.” They stop the token from being read and exfiltrated. They don’t stop an XSS from making authenticated requests from within your page while the user is logged in. httpOnly limits damage; it doesn’t prevent it.
“Just put the JWT in a cookie.” If you’re going to use a stateful httpOnly cookie anyway, it’s worth asking whether you need a JWT at all. A self-contained token earns its keep when verification must be stateless and distributed across services; for a single first-party web app talking to its own backend, a plain opaque session cookie backed by server-side session state is often simpler and — as the next article will make vivid — far easier to revoke. Choosing a JWT should be a decision about your verification architecture, not a reflex.
Two quick notes on scope. This debate is specific to browsers: native mobile apps should use the platform’s secure storage (iOS Keychain, Android Keystore) rather than anything resembling localStorage, and desktop or Electron apps carry their own caveats. And nothing here replaces transport security — all of it assumes HTTPS everywhere, without which the storage question is moot.
Where this leaves you
The arrangement that keeps coming out on top — a short-lived access token paired with a well-guarded refresh token, or a BFF that keeps the token off the browser entirely — reduces how easily a token is stolen. But “reduces” is the honest word: given a real XSS, some exposure remains, and tokens leak through other channels too. Storage decides how easily a token is stolen; it can’t decide how much a stolen one is worth once it’s out. That second question — keeping the useful life of any single token short, rotating credentials so theft becomes detectable, and being able to shut a compromised session down — is the token lifecycle, and it’s the subject of the final article: short lifetimes, refresh-token rotation, and revocation.