You already know localStorage has a nicer API than cookies and
can hold a bigger payload. That's not the decision that matters. The decision that matters is
this: a token or session ID sitting in the browser is a bearer credential.
Whoever holds it is treated as the user, and the two storage mechanisms hand that credential
to two completely different attackers. localStorage is exposed to
XSS ; cookies are exposed to CSRF . Neither
option is "more secure" in the abstract. They trade one attack surface for the other, and
which one you can better afford to defend depends on your architecture.
What an XSS bug actually gets, depending on where you put the token
Say a third-party script your page loads (an ad, an analytics snippet, a compromised npm package bundled into your app) gets to run arbitrary JavaScript on your page. That's XSS, full stop, regardless of storage choice. The storage decision doesn't stop the injected script from running; it only decides which of two attack vectors that script gets access to:
- Exfiltration: the script reads the credential's raw value and ships it to a server the attacker controls. Once that happens, the token is a portable, reusable credential: the attacker can replay it from their own infrastructure, at any time, from anywhere, for as long as it's valid.
- In-session, on-behalf-of abuse: the script never gets to see the credential, but it's still executing in the user's authenticated browser tab, so it can issue requests that carry the credential automatically. The attacker can't take the credential home with them, but they can act as the user, in real time, for as long as the script keeps running on the page.
localStorage is readable by any script on the page, so it hands the attacker
exfiltration. An HttpOnly cookie can't be read by JavaScript at all, so it closes
off exfiltration, but the browser still attaches it to same-origin requests automatically,
which leaves the in-session, on-behalf-of vector open.
That last box is the nuance worth sitting with: HttpOnly
doesn't make XSS harmless, it just closes off Vector 1 and leaves Vector 2 open. With
localStorage, the attacker walks away with a portable credential (Vector 1) they
can replay from their own infrastructure, indefinitely, until it's revoked or expires. With an
HttpOnly cookie, the script can't exfiltrate anything reusable, but it can still
open Vector 2, acting on behalf of the user, in real time, for as long as it's running on the
page. Smaller blast radius, not zero risk.
Choosing cookies means CSRF is now your problem
The mechanism that protects a cookie from XSS (the browser attaches it automatically) is the same mechanism CSRF exploits. If a user is logged into your site and visits an attacker's page, that page can trigger a request to your API and the browser will happily attach the session cookie, because it doesn't know the request wasn't initiated by you.
-
SameSiteis the main defense:Laxblocks the cookie on cross-site subrequests (images, fetches, form posts from another origin) but still sends it on top-level GET navigations, e.g. clicking a link from an email, so never let a GET request change state.Strictcloses that gap but can break flows like arriving at your site from an external link while logged out. -
For anything
SameSitedoesn't fully cover, add a CSRF token: a value the server hands the page (not the attacker's page) that must be echoed back on state-changing requests.
// Recommended for a session cookie: unreadable by JS, HTTPS-only, not sent cross-site
res.cookie("session", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
}); A pattern most SPAs actually reach for: memory + refresh cookie
In practice, teams building an SPA that also needs to attach a bearer
token to cross-origin API calls rarely stop at "just use localStorage."
The common production pattern splits the credential in two: a short-lived access token that
never touches storage at all, and a long-lived refresh token locked behind
HttpOnly.
- Access token in a JS variable (module-level memory, not
localStorageorsessionStorage). It's gone on refresh and never sits in an inspectable, persistent location. An attacker needs code running at that moment to grab it, not just a one-time read of storage. - Refresh token in an
HttpOnly,Secure,SameSitecookie, sent only to a dedicated/auth/refreshendpoint. JavaScript never sees it, so XSS can't steal it outright. - Keep the access token's lifetime short (minutes). If it does leak mid-session, the window an attacker can use it in is small, and rotating the refresh token on each use limits reuse further.
// Access token: kept in a module-level variable only, never localStorage,
// never sessionStorage. Gone on refresh, gone if the tab closes.
let accessToken = null;
export function setAccessToken(token) {
accessToken = token;
}
export function authFetch(url, options = {}) {
return fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
});
}
// When a request comes back 401, the refresh token (sitting in an HttpOnly,
// Secure, SameSite cookie the browser attaches automatically) renews it.
// The refresh token never touches JavaScript, so an XSS bug can't read it.
async function refresh() {
const res = await fetch("/auth/refresh", { credentials: "include" });
const { accessToken: next } = await res.json();
setAccessToken(next);
} Deciding for your app
- First-party web app, same site serves the API: reach for an
HttpOnly,Secure,SameSitecookie and add a CSRF token for state-changing routes. You're trading a narrow, well-understood CSRF surface for immunity to the much more common XSS-steals-everything failure. - SPA or mobile app calling your API (and maybe third-party APIs) with an
Authorizationheader: you need the token in JavaScript's reach. Minimize the damage with the memory +HttpOnlyrefresh-cookie pattern above rather than a long-lived token sitting inlocalStorage. - Either way, XSS is the actual fire to prevent. The storage choice only decides what happens after attacker script is already running on your page. A CSP that restricts script sources is the first line of defense; the storage decision is your fallback when that line fails.
Check your understanding
What's the exposure, and how would an HttpOnly cookie change it?
A site keeps its auth token in `localStorage`. A third-party script it loads has an XSS flaw. What's the exposure, and how would an `HttpOnly` cookie have changed it?
That completes the authentication side: a login becomes a credential, and the credential has a safe home. Next we cross to the other half of the taxonomy: authorization. We know who you are; what are you allowed to do?