Ask ten SPA tutorials where to put a JWT after login and most will tell you localStorage.setItem('token', ...) — a pattern OWASP's Session Management Cheat Sheet has explicitly warned against for years, precisely because anything written there is readable by any script running on the page. That includes a script an attacker planted through a single unsanitized render, a vulnerable npm package, or a third-party ad tag. There is no browser permission model that scopes localStorage to "your code only" — CWE-79 (cross-site scripting) and CWE-522 (insufficiently protected credentials) intersect exactly here, and the intersection is silent: a stolen token doesn't trigger a failed-login alert, a new-device email, or an MFA prompt. It just gets replayed. React, Vue, and Angular all shipped built-in output-escaping specifically to reduce this attack surface, yet dangerouslySetInnerHTML, v-html, and raw innerHTML calls remain common enough that XSS is still listed under OWASP's Top 10 injection category. The real question for SPA architects isn't whether XSS will ever be attempted against their app — it's what an attacker gets if one script ever slips through. This post walks through the storage decision, the OAuth refresh-token pattern that limits blast radius, and where the residual risk lands.
Why is localStorage the wrong place for access and refresh tokens?
Because localStorage (and sessionStorage, and any cookie without the HttpOnly flag) is fully readable by JavaScript, and XSS is JavaScript execution in your origin. A single injected <script> tag or a compromised dependency can call localStorage.getItem('token') and POST it to an attacker's server in one line — no browser warning, no CORS block, because the read happens same-origin before exfiltration. OWASP's guidance is unambiguous on this point: session tokens shouldn't be stored anywhere JavaScript can reach, because doing so converts every XSS finding, no matter how minor it looked in a bug bounty report, into full session takeover. This is also why the CWE taxonomy treats it as a protected-credentials failure (CWE-522) layered on top of whatever injection flaw (CWE-79) let the attacker's script run in the first place — the storage choice determines the ceiling of an XSS bug's impact, not just its existence.
Do HttpOnly cookies actually solve the problem?
They solve token theft, not XSS itself. A cookie marked HttpOnly is invisible to document.cookie and to any JavaScript read attempt — an attacker's injected script simply cannot retrieve the value, which is exactly why OWASP recommends it for session identifiers. But an attacker's script running in an authenticated page doesn't need to read the cookie to abuse it: the browser attaches cookies automatically to same-origin requests, so malicious JS can still fire authenticated fetch() calls that ride along on the victim's session. HttpOnly stops the attacker from carrying the token off-site and replaying it later, from a different machine, indefinitely — it does not stop in-session abuse while the XSS payload is live. That distinction matters for incident response: an HttpOnly-cookie compromise is bounded to the browser tab and session lifetime; a stolen bearer token in localStorage is unbounded until you revoke it.
What does the OAuth/OIDC-recommended pattern for SPAs actually look like?
The pattern that OWASP, Auth0's engineering guidance, and IETF OAuth Security Best Current Practice documents converge on splits tokens by lifetime and exposure. Short-lived access tokens (commonly 5-15 minutes) live only in memory — a JavaScript variable in application state, never written to any storage API — so they evaporate on page reload and are never durably exposed even if read mid-session. The longer-lived refresh token, which is what actually matters to protect, goes in a cookie marked HttpOnly, Secure, and SameSite=Strict or Lax, so it's inaccessible to document.cookie reads and only ever transmitted over TLS to your own domain. The SPA calls a silent-refresh endpoint that reads the cookie server-side and issues a new short-lived access token, keeping the long-lived secret out of JS reach entirely for the session's duration.
What is refresh-token rotation and why does it matter here?
Refresh-token rotation means every time a refresh token is used, the server issues a brand-new one and invalidates the old one — so each refresh token is single-use. This matters because it bounds the damage if a refresh token cookie is ever exfiltrated through some other channel (a misconfigured proxy log, a network capture, a server-side bug): the moment the legitimate client tries to use its refresh token again, the server sees a token that's already been consumed by the attacker, which is the reuse-detection signal. A well-implemented rotation scheme treats that reuse as a compromise indicator and revokes the entire token family immediately, forcing re-authentication. Without rotation, a leaked refresh token is a standing credential valid for its full lifetime — often days or weeks — with no way to distinguish legitimate use from attacker use until damage is already done.
If cookies are safer, what do you give up by moving off localStorage?
Two things: statelessness on the client, and CSRF resistance you now have to build back in. Cookies are automatically attached by the browser to matching requests, which is the same property that makes HttpOnly safe against XSS-read — but it also means a malicious page on another origin can trigger requests that carry your cookies along, the classic cross-site request forgery pattern. The standard mitigation is SameSite=Strict or Lax on the cookie itself, paired with a CSRF token pattern (double-submit cookie or synchronizer token) for state-changing requests, so a forged cross-origin request lacks the token even though the session cookie rides along. Cookies also cap out around 4KB per cookie and add themselves to every matching request's headers, which is a real (if usually minor) bandwidth and payload-size consideration for APIs that were designed around bearer-token headers. None of this is exotic engineering — it's the same cookie-session model the web ran on before SPAs and bearer tokens became fashionable — but it is work a team has to actually do, not a checkbox in an OAuth library's default config.
How should a team decide, and what should they check first?
Start from what an XSS bug in your app would actually expose today. If your access and refresh tokens both sit in localStorage, the honest answer is "everything, indefinitely, silently" — and the fix isn't a WAF rule or a stricter Content-Security-Policy alone (CSP helps reduce XSS likelihood but is defense-in-depth, not a token-storage substitute). The concrete checklist: move refresh tokens into HttpOnly/Secure/SameSite cookies, keep access tokens in memory only, implement refresh-token rotation with reuse detection on the auth server, and add CSRF protection for the state-changing endpoints those cookies now touch. Because the underlying enabler is almost always a dependency-introduced or template-injection XSS rather than something a security review of your own auth code would catch, pairing this architecture with dependency and static-analysis scanning that flags injection-prone rendering paths (CWE-79) closes the loop — the storage pattern limits blast radius, but keeping XSS out of the app in the first place is still the first line of defense.