Back to blog

2026-05-26

Sessions and refresh tokens in SPAs: patterns and common mistakes

Storage, rotation, and realistic threats when an SPA consumes APIs with cookies or bearer tokens.

A SPA (React, Vue, Svelte…) calling your API must place short-lived access tokens and long-lived refresh tokens somewhere. Decks say “use HttpOnly cookies”; in production many teams drop JWTs into localStorage for convenience—and hand XSS long-lived power.

This article is framework-agnostic; wire it with Auth.js, Lucia, better-auth or bespoke sessions—the point is the threat model, not the vendor badge.

Core threats

  • XSS: hostile JS reads localStorage / sessionStorage. HttpOnly cookies are not script-readable → preferred for refresh (and often session ids).
  • CSRF: browsers auto-send cookies. Mitigations: SameSite=Lax or Strict for typical navigations, CSRF tokens or double-submit cookies when you must use SameSite=None, or avoid cookies on cross-origin APIs and rely on bearer tokens in memory (worsens XSS urgency).
  1. Access token: 5–15 minute TTL, kept in JS memory (volatile store) when possible. If you need cross-tab continuity, prefer an API-wrapped session cookie over localStorage.
  2. Refresh token: HttpOnly + Secure + tight path /auth/refresh, renewed via dedicated POST. Rotate every refresh: issue a new refresh server-side and invalidate the previous token (jti table or session row).
  3. Revocation: server-side version or denylist so a stolen refresh dies after one round trip.
Set-Cookie: refresh=...; HttpOnly; Secure; Path=/auth/refresh; SameSite=Lax; Max-Age=...

Avoid

  • Refresh tokens in localStorage unless the threat model explicitly allows it.
  • Long access tokens “to reduce refresh traffic”—they widen the blast radius on device compromise.
  • Concurrent refresh storms without deduplication—four tabs can mint four sessions; share a refreshPromise mutex or make the endpoint idempotent.

SPA + API on different hosts

If the UI is app.example.com and API api.example.com:

  • Cross-subdomain cookies: Domain=.example.com + Secure + SameSite aligned with your OAuth/PKCE flow.
  • Alternative: Backend-for-Frontend on the same site as the UI to proxy—cuts CORS/cookie pain.

Logout

Clear refresh cookies (Max-Age=0), invalidate jti server-side, wipe in-memory client state. Client-only logout without an API call leaves a valid refresh behind.

RFC 9700 (BCP 240): refresh tokens for public clients

RFC 9700 (OAuth 2.0 Security BCP, Jan 2025) is the updated guidance auditors expect. The actionable bit is refresh-token replay detection:

  • Public clients (typical browser SPA without a client secret) require either refresh-token rotation or sender-constrained refresh tokens (DPoP / mTLS). With rotation, each refresh issues a new refresh token and invalidates the prior one; concurrent reuse means someone presents an already-invalidated token, letting the AS detect compromise and revoke the grant chain.
  • Useful pull quote: “If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token…” (RFC 9700 §4.14).

Major IdPs encode these rules in their SPA integrations—custom stacks should too.

References

OWASP Session Management Cheat Sheet, RFC 9700, and RFC 6749 refresh semantics—adapt if you ship custom sessions instead of raw OAuth grants.

Summary

HttpOnly refresh, short in-memory access, rotate + revoke server-side, dedup refresh. localStorage for long-lived secrets remains the most expensive shortcut we see in the field.

Want to ship ideas like these into your product?

Share context, constraints, and goals. We will tell you if partnering makes sense and how to frame the first step.