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=LaxorStrictfor typical navigations, CSRF tokens or double-submit cookies when you must useSameSite=None, or avoid cookies on cross-origin APIs and rely on bearer tokens in memory (worsens XSS urgency).
Recommended high-level pattern
- 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. - 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 (jtitable or session row). - 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
localStorageunless 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
refreshPromisemutex 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+SameSitealigned 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.