JavaScript Token Refresh, the In-Memory-Only Storage Discipline, and HTML's Credential Management and Secure-Context Boundaries
The Ops lesson made one claim the browser has to obey: a leased credential must expire before it matters, and no copy of it may outlive the lease. A single-page application is a credential consumer exactly like a fleet agent. It authenticates once, receives a token, and then makes dozens of authenticated calls with it. The only question is whether that token behaves like a lease or like the old static password taped under the keyboard.
The browser makes the wrong answer easy. localStorage is right there, it survives refreshes, and dropping a token into it makes the login "stick" with one line of code. It also hands that token to every script on the page, keeps it long after the session should have ended, and leaves it sitting in a store that any cross-site-scripting payload can read. That is the client-side version of the copy that outlives the lease — the exact failure the Ops lesson warned against, reintroduced for developer convenience.
This lesson builds the browser as a disciplined lease consumer. Hold a short-lived access token in memory only. Refresh it silently before it expires, which is the client-side rotation. And lean on the two boundaries the platform gives you — the secure context and the Credential Management API — so credentials only ever move over channels the browser can vouch for.
Modern browser auth splits the credential into two parts with opposite lifetimes. The access token is short-lived — five to fifteen minutes — and accompanies every API call. The refresh token is longer-lived, used only to mint new access tokens, and never touches an ordinary API endpoint. The split exists for precisely the blast-radius reason from the Ops lesson: the credential that travels widely (the access token, in every request header) is the one that expires fastest, so a leaked access token is worthless in minutes.
Flanagan's treatment of the web platform (Definitive Guide 7e, §15) frames the browser as a set of capability APIs gated by origin and context; a credential is just another capability the page must acquire and must not leak across those gates. The access token is the capability you hold briefly. The refresh token is the capability you use to renew — and the strong pattern keeps the refresh token out of JavaScript entirely, in an HttpOnly cookie the script cannot read, so a script-level compromise cannot steal the thing that mints new tokens.
The access token lives in a closure variable, not in any persistent store. A small auth module owns it, exposes a function to get a valid token, and refreshes transparently when the current one is close to expiry. Nothing outside the module can read the raw token; callers ask for it and get one that is guaranteed fresh.
// auth.js — the token lives ONLY in this closure. No localStorage.
let accessToken = null;
let expiresAt = 0; // epoch ms
let refreshInFlight = null; // dedupe concurrent refreshes
async function refresh() {
// refresh token rides an HttpOnly cookie; browser attaches it.
const res = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include', // send the HttpOnly cookie
});
if (!res.ok) { accessToken = null; throw new Error('refresh_failed'); }
const { access_token, expires_in } = await res.json();
accessToken = access_token;
expiresAt = Date.now() + expires_in * 1000;
return accessToken;
}
export async function getToken() {
// refresh 30s early so no request ever carries an expired token
if (!accessToken || Date.now() > expiresAt - 30_000) {
// collapse a burst of callers onto ONE refresh (the drain race)
refreshInFlight = refreshInFlight || refresh().finally(() => {
refreshInFlight = null;
});
await refreshInFlight;
}
return accessToken;
}
Two details carry the Ops discipline. The thirty-second early refresh is the client-side version of refreshing before the lease lapses — no outbound request ever carries a token that expires mid-flight. The refreshInFlight dedupe is the client-side drain: when ten components all call getToken() at once and the token is stale, they collapse onto a single refresh instead of firing ten, which is the same "one valid credential every consumer can reach" invariant the rotation state machine held on the server.
Every authenticated request goes through a thin wrapper that calls getToken() first, so the freshness guarantee is impossible to forget:
export async function api(path, opts = {}) {
const token = await getToken();
const res = await fetch(path, {
...opts,
headers: { ...opts.headers, Authorization: `Bearer ${token}` },
});
if (res.status === 401) {
// token rejected mid-lease: force one refresh, retry ONCE
accessTokenReset();
const retryToken = await getToken();
return fetch(path, { ...opts,
headers: { ...opts.headers, Authorization: `Bearer ${retryToken}` } });
}
return res;
}
The single retry on a 401 is the recover move from the Ops lesson, at request scale: a credential can be revoked out from under you at any instant (the server rotated, or isolated your session), so treat a 401 not as an error to surface but as a signal to re-lease once and try again. Retry exactly once — a second 401 means the refresh itself is dead, and the honest response is to send the user back to login, not to loop.
JavaScript holds the token; the HTML platform decides what channels a credential may move over at all. Two boundaries matter.
Nearly every credential-bearing API — Credential Management, Service Workers, the Web Crypto API — is gated to a secure context: HTTPS, or localhost. Over plain HTTP the browser refuses to expose them. Serve the app over TLS or the disciplines below simply do not exist for you.
The refresh cookie is set server-side with HttpOnly (invisible to script), Secure (HTTPS only), and SameSite=Strict or Lax (not sent on cross-site requests). Those three flags together put the renew capability outside the reach of XSS and CSRF at once.
navigator.credentials lets the browser store and retrieve credentials through a vetted interface instead of ad-hoc form scraping, enabling passkeys and federated sign-in without the page ever handling a raw password string.
The Credential Management API is the HTML-side lease boundary. Instead of the page reading a password out of an input and shipping it, the browser mediates: it stores the credential, and hands the page a short-lived assertion that authentication succeeded. The raw secret never enters your JavaScript, which means it cannot be logged, cached, or dropped into localStorage by a careless line — the failure is prevented by the platform, not by developer vigilance.
<!-- The secure-context requirement is not optional decoration. -->
<!-- Served over HTTPS, this triggers the browser's credential UI; -->
<!-- served over http:// the navigator.credentials call is undefined. -->
<script>
if (window.isSecureContext && 'credentials' in navigator) {
// request a stored/federated credential via the vetted API
const cred = await navigator.credentials.get({ password: true,
mediation: 'optional' });
if (cred) { /* browser mediated it; no raw secret in our JS */ }
} else {
// NOT a secure context — refuse to handle credentials at all
console.warn('insecure context: credential APIs unavailable');
}
</script>
The window.isSecureContext guard is the browser's admission gate, and it fails closed exactly like the Ops admission webhook: if the page is not served over a channel the browser trusts, the credential path does not open. That is the same instinct as refusing to run an agent whose lease cannot be minted — refuse the credential operation rather than degrade to an insecure fallback.
localStorage for the token. The signature mistake. A token in localStorage is readable by every script including any XSS payload, persists across sessions with no expiry the store enforces, and is the copy that outlives the lease. Keep the access token in a closure variable and the refresh token in an HttpOnly cookie; never localStorage, never sessionStorage for either.
The refresh stampede. Without the refreshInFlight dedupe, a page that fires ten parallel requests on a stale token launches ten refreshes, and depending on the server's rotation policy nine of them may invalidate each other. Collapse concurrent refreshes onto one promise — the client-side drain.
Silent downgrade to HTTP. A credential API guarded only by 'credentials' in navigator but not isSecureContext will happily run over plain HTTP in a mixed deployment and leak the negotiation. Guard on the secure context explicitly, and fail closed when it is absent.
Infinite 401 retry. Retrying a rejected request until it succeeds turns a dead session into a busy loop hammering the auth endpoint. Retry once after a forced refresh; a second failure ends the session.
The browser is a credential consumer, and the same three moves that discipline a fleet discipline a page. Hold the access token in memory as a short lease, refresh it silently before it lapses, and let the platform's secure context and HttpOnly boundaries keep the renew capability out of script's reach. The token that never touches localStorage is the token that cannot outlive its session.
Write the auth module once, route every request through the wrapper, and the freshness and blast-radius guarantees hold everywhere by construction — the same reason the Ops lesson made rotation a state machine instead of a careful habit. The Cert lesson next takes these same leases down to the cluster, where Kubernetes mints projected ServiceAccount tokens with an expiry and rotates them under the pod without the container ever noticing.