<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Storing Tokens in the Browser

Storing Tokens in the Browser

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.

Injected script runs on your page (XSS) reads localStorage.token tries document.cookie localStorage.token HttpOnly cookie ✕ blocked, never read ships it to the attacker's server fetch('/api/…') same-origin: cookie attaches automatically VECTOR 1: EXFILTRATION attacker's server, reusable anywhere, until it expires VECTOR 2: ON-BEHALF-OF your own API, acts as the user now, cookie never leaves the browser
With localStorage, injected script reads the token directly and can exfiltrate it to an attacker-controlled server, where it's reusable from anywhere until it expires or is revoked. With an HttpOnly cookie, the same script can't read the value, so exfiltration is closed off, but it can still act on behalf of the user in real time, issuing same-origin requests the cookie attaches to automatically.

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.

  • SameSite is the main defense: Lax blocks 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. Strict closes that gap but can break flows like arriving at your site from an external link while logged out.
  • For anything SameSite doesn'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 localStorage or sessionStorage). 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, SameSite cookie, sent only to a dedicated /auth/refresh endpoint. 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, SameSite cookie 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 Authorization header: you need the token in JavaScript's reach. Minimize the damage with the memory + HttpOnly refresh-cookie pattern above rather than a long-lived token sitting in localStorage.
  • 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

Question 1 of 3

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?