Token storage trade-offs, silent refresh, the httpOnly cookie BFF pattern, OAuth2 PKCE in SPAs, and cross-tab logout, written from things I broke in production.
It was a Tuesday around 09:30 local. A real-time trading dashboard I’d architected was supposed to come alive at market open, and for the first 74 seconds it did. Then auth started dropping users in waves. Sessions looked valid, but the WebSocket upgrade kept getting rejected because the bearer token had drifted past its exp and the client was racing to reconnect before the refresh call returned. I scaled gateway pods three times before I realized I was feeding the fire. Auth on the frontend isn’t a scaling problem. It’s a state machine problem, and the state machine is almost always wrong the first time.
So this is how I think about frontend auth now, after a few of these.
The honest answer is, nowhere in JavaScript if you can help it. localStorage is convenient and it’s also a free XSS exfiltration target. sessionStorage is no better, just shorter-lived. In-memory is fine for the access token if you can re-fetch it on tab reload, which usually means a refresh token sitting in an httpOnly cookie. That’s the pattern I default to now.
If you’re stuck on a pure SPA with no backend cooperation, an in-memory access token plus a short TTL (5 to 10 minutes) and a silent refresh is the least-bad option. If you have any backend, route auth through a Backend-for-Frontend (BFF) and let the cookie do the work. The browser already knows how to handle cookies safely. We don’t need to reinvent that with JSON.parse(localStorage.getItem('auth')).
Here’s the in-memory variant. It’s the one I reach for when the auth server isn’t ours.
import { create } from 'zustand';
type AuthState = {
accessToken: string | null;
expiresAt: number | null;
setSession: (token: string, ttlSeconds: number) => void;
clear: () => void;
};
export const useAuth = create<AuthState>((set) => ({
accessToken: null,
expiresAt: null,
setSession: (token, ttlSeconds) =>
set({
accessToken: token,
expiresAt: Date.now() + ttlSeconds * 1000,
}),
clear: () => set({ accessToken: null, expiresAt: null }),
}));
export function getAccessToken(): string | null {
const { accessToken, expiresAt } = useAuth.getState();
if (!accessToken || !expiresAt) return null;
if (Date.now() >= expiresAt - 30_000) return null;
return accessToken;
}
The 30-second skew on expiresAt is the small detail that matters. Clocks drift. Your token is “valid” by your clock for 12 seconds and the API has already rejected it. Treat the expiry as “use until 30 seconds before it dies”.
This is where most homegrown auth code falls apart. Five components all notice the token is expired at the same moment, they all fire POST /auth/refresh in parallel, and the refresh token rotation logic on the server marks four of them as replay attacks. Suddenly your user is logged out for “security reasons”.
The fix is single-flight. One in-flight refresh, queued waiters.
type RefreshResult = { token: string; ttlSeconds: number };
let inFlight: Promise<RefreshResult> | null = null;
export async function refreshAccessToken(): Promise<RefreshResult> {
if (inFlight) return inFlight;
inFlight = (async () => {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`refresh_failed_${res.status}`);
const data = (await res.json()) as RefreshResult;
useAuth.getState().setSession(data.token, data.ttlSeconds);
return data;
} finally {
inFlight = null;
}
})();
return inFlight;
}
export async function authedFetch(input: RequestInfo, init: RequestInit = {}) {
let token = getAccessToken();
if (!token) {
const refreshed = await refreshAccessToken();
token = refreshed.token;
}
const res = await fetch(input, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${token}`,
},
credentials: 'include',
});
if (res.status !== 401) return res;
const refreshed = await refreshAccessToken();
return fetch(input, {
...init,
headers: {
...(init.headers || {}),
Authorization: `Bearer ${refreshed.token}`,
},
credentials: 'include',
});
}
The credentials: 'include' on the refresh call is what carries the httpOnly refresh cookie. Your CORS config has to allow it, and your frontend origin has to be on the server’s allowlist. Forgetting credentials is one of those debugging sessions where everything looks right and nothing works.
For anything I build with our own backend, this is the default now. The browser never sees a token. The SPA talks to a thin backend on the same eTLD+1, that backend holds the session in an httpOnly, SameSite=Strict, Secure cookie. The BFF forwards calls to the actual API with a server-side token it manages.
You get three wins. XSS can’t read the session because JavaScript literally can’t see the cookie. CSRF you handle with SameSite plus a double-submit token on state-changing routes. And rotating refresh tokens never touch the client, which means you can be aggressive about rotation without worrying about a stale tab triggering a family-detection logout.
OAuth2 PKCE still applies, but you run it on the BFF, not the browser. The frontend’s job becomes “redirect to login, redirect back, ask the BFF if I’m authenticated”. Simple.
import { useEffect, useState } from 'react';
type Me = { id: string; email: string } | null;
export function useSession() {
const [me, setMe] = useState<Me>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch('/api/me', { credentials: 'include' })
.then(async (res) => {
if (cancelled) return;
if (res.status === 401) {
setMe(null);
return;
}
setMe(await res.json());
})
.catch(() => !cancelled && setMe(null))
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, []);
return { me, loading };
}
That’s the whole client-side session check. No tokens, no expiry math, no localStorage. The BFF does the work.
A user opens five tabs of your app, signs out in one, the other four still think they’re authenticated until the next 401. That’s a security bug, even if it’s a small one. BroadcastChannel solves it cleanly and the API is older than people give it credit for.
const channel =
typeof BroadcastChannel !== 'undefined'
? new BroadcastChannel('auth')
: null;
export function broadcastLogout() {
channel?.postMessage({ type: 'logout' });
useAuth.getState().clear();
window.location.assign('/login');
}
channel?.addEventListener('message', (event) => {
if (event.data?.type === 'logout') {
useAuth.getState().clear();
window.location.assign('/login');
}
});
Pair this with a storage event listener as a fallback for the small set of browsers that don’t ship BroadcastChannel, and you’re done.
BroadcastChannel for cross-tab logout. storage event as fallback.