When a user clicks Logout on your React‑based dashboard and the app silently redirects them back in minutes, you know something went seriously wrong. In a recent breach, an attacker swapped a stolen access token for a fresh one within seconds, letting them roam an entire SaaS tenant. The culprit? An oversimplified token‑storage strategy that ignored XSS and CSRF realities.
- Store refresh tokens in httpOnly SameSite cookies; keep access tokens in memory only.
- Adopt the Backend‑For‑Frontend (BFF) pattern to isolate token handling.
- Rotate refresh tokens on every use and detect reuse to block replay attacks.
- Implement short‑lived access tokens (5‑15 min) and a lightweight deny‑list for revoked refresh tokens.
- Hardening requires CSP, same‑site cookie attributes, and observability for abnormal token activity.
Before you start: Node ≥ 20, Express 4.x, React 18, Axios 1.x, Redis 7, and a basic understanding of OAuth 2.0 flows.
Secure SPA authentication with JWTs: the concise answer
Secure SPA authentication requires separating long‑lived refresh tokens and short‑lived access tokens. Store refresh tokens in httpOnly, SameSite cookies to prevent XSS theft. Keep access tokens in JavaScript memory and automate their renewal via a protected API call using the refresh token. Always implement token rotation to invalidate used refresh tokens, mitigating replay attacks.
Understanding JWT‑Based Authentication for Single Page Applications
The Core Challenge: Statelessness vs. Session Security
Stateless JWTs let a server verify a request without persisting session data, but the same property makes revocation and theft detection harder. Classic server‑side sessions keep state in a database, offering immediate invalidation at the cost of extra round‑trips and scaling pressure.
Anatomy of JWT: Breaking Down Access and Refresh Tokens
A JWT consists of three Base64URL parts — header, payload, and signature. Access tokens usually carry scopes and expire after 5‑15 minutes. Refresh tokens carry a longer lifespan (days or weeks) and are not meant to be sent on every API call.
The SPA Security Model: Why Classic Sessions Fall Short
SPAs run entirely in the browser, so they cannot rely on server‑side session cookies that are automatically sent with each request. If you embed a session ID in a cookie, the browser will still send it to any sub‑domain, opening the door to CSRF. Conversely, storing a JWT in localStorage makes it trivial for any XSS payload to exfiltrate the token.
“Storing tokens in browser local storage is the number one JWT anti‑pattern because any script running on your domain can access it, making XSS a full account compromise.” – Auth0 Security Bulletin
Architecting a Secure JWT Flow: Principles & System Design
The Backend‑For‑Frontend (BFF) Pattern Simplified
Instead of letting the SPA talk directly to the resource server, introduce a thin BFF that lives on the same origin as the UI. The BFF handles cookie‑based refresh tokens, exchanges them for short‑lived access tokens, and proxies API calls. This isolates token logic from the client and eliminates the need for Authorization: Bearer headers in JavaScript.
“Our microservices API gateway tutorial shows how a BFF can be deployed alongside a React frontend using Kubernetes, providing a clear separation of concerns.”
Token Storage: In‑Memory vs. httpOnly Cookies – The Critical Trade‑off
| Storage | XSS Exposure | CSRF Exposure | Persistence | Typical Use |
|---|---|---|---|---|
memory (JS var) | none (no DOM access) | none (not sent automatically) | lost on reload | short‑lived access token |
httpOnly cookie | none (JS cannot read) | mitigated with SameSite=Strict | survives reload | refresh token |
localStorage | full (readable) | none (manual header) | survives reload | anti‑pattern |
In‑memory storage means the token vanishes on page refresh, forcing a silent refresh flow. httpOnly cookies survive reloads but must be defended against CSRF; the SameSite=Strict attribute and CSRF‑double‑submit tokens close that gap.
Token Rotation & Reuse Detection: A Key to Mitigating Breach Impact
When a refresh token is used, the BFF must:
- Verify it against the deny‑list (Redis set).
- Issue a new access token and a new refresh token.
- Store the new refresh token in the httpOnly cookie.
- Add the old refresh token hash to the deny‑list with a short TTL (e.g., 5 minutes).
If the same refresh token appears again, the system treats it as a replay attempt and blocks the user.
flowchart TD
A[Client requests /refresh] --> B[BFF validates httpOnly cookie]
B --> C{Token in deny‑list?}
C -->|No| D[Generate new Access + Refresh]
D --> E[Set new httpOnly cookie]
E --> F[Return Access token]
C -->|Yes| G[Reject & log incident]
Implementation Guide: A Secure SPA Authentication Service
Building a Robust Node.js/Express Token Endpoint
// server.js – Node 20, Express 4.18
const express = require('express');
const jwt = require('jsonwebtoken'); // npm i jsonwebtoken@9
const redis = require('redis'); // npm i redis@4
const cookieParser = require('cookie-parser');
const app = express();
const redisClient = redis.createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);
app.use(express.json());
app.use(cookieParser());
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
const ACCESS_TTL = '10m';
const REFRESH_TTL = '7d';
// Helper: async error wrapper
const asyncWrap = fn => (req, res, next) => fn(req, res, next).catch(next);
// Refresh endpoint – rotates refresh token securely
app.post('/auth/refresh', asyncWrap(async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.sendStatus(401);
// 1️⃣ Verify signature & extract payload
const payload = jwt.verify(token, REFRESH_SECRET);
// 2️⃣ Check deny‑list (Redis)
const exists = await redisClient.sIsMember('revokedRefresh', payload.jti);
if (exists) {
console.warn('Replay attack detected for jti:', payload.jti);
return res.status(403).json({ error: 'Refresh token reused' });
}
// 3️⃣ Issue new tokens
const newJti = crypto.randomUUID();
const access = jwt.sign({ sub: payload.sub, scope: payload.scope }, ACCESS_SECRET, { expiresIn: ACCESS_TTL });
const refresh = jwt.sign({ sub: payload.sub, scope: payload.scope, jti: newJti }, REFRESH_SECRET, { expiresIn: REFRESH_TTL });
// 4️⃣ Rotate: blacklist old token, set new cookie
await redisClient.sAdd('revokedRefresh', payload.jti);
await redisClient.expire('revokedRefresh', 300); // 5 min TTL
res.cookie('refreshToken', refresh, {
httpOnly: true,
sameSite: 'strict',
secure: true,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});
res.json({ accessToken: access });
}));
The endpoint handles race conditions by blacklisting the current token before issuing the next one, guaranteeing that a concurrent request cannot reuse the same refresh token.
Frontend Logic: Handling Token Lifecycle with Axios/React
// authHook.ts – React 18, Axios 1.3
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true // send httpOnly cookie
});
export function useAuth() {
const [accessToken, setAccessToken] = useState<string | null>(null);
// Silent token refresh
const refresh = useCallback(async () => {
try {
const { data } = await api.post('/auth/refresh');
setAccessToken(data.accessToken);
scheduleRefresh(data.accessToken);
} catch (err) {
console.error('Refresh failed', err);
setAccessToken(null);
}
}, []);
// Schedule next refresh 1 min before expiry
const scheduleRefresh = (token: string) => {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresInMs = payload.exp * 1000 - Date.now() - 60_000;
setTimeout(refresh, Math.max(expiresInMs, 0));
};
// Attach access token to outbound calls
api.interceptors.request.use(cfg => {
if (accessToken) cfg.headers.Authorization = `Bearer ${accessToken}`;
return cfg;
});
// Initial silent refresh on mount
useEffect(() => { refresh(); }, [refresh]);
return { accessToken, refresh };
}
The hook never writes the token to localStorage; it lives purely in React state (memory). On each page reload the useEffect triggers a silent refresh that re‑populates the in‑memory token.
Code Example: Secure Refresh Token Flow with Race Condition Handling
When two tabs call /auth/refresh simultaneously, both receive the same cookie. The server’s blacklist‑first strategy ensures the second request finds its old jti already stored and receives a 403. The client can catch this and force a full login, preventing token leakage.
Production Considerations & Security Hardening
Token Revocation Strategies: Blacklists vs. Short‑Lived Tokens
Revoking every JWT is impossible; the pragmatic approach mixes:
- Ultra‑short access tokens (5‑15 min) that naturally expire.
- Refresh token deny‑list limited to the last few minutes, stored in Redis (highly available).
Our “Secure JWT Authentication with Redis (2026 Guide)” walks through a sharded Redis setup that keeps the deny‑list at sub‑millisecond latency.
Mitigating XSS and CSRF in a JWT‑Based SPA
- Deploy a strong Content‑Security‑Policy header (
script-src 'self'etc.) – see the OWASP CSP Cheat Sheet. - Use
SameSite=Stricton refresh‑token cookies; add a double‑submit CSRF token for any state‑changing POST routes.
app.use((req, res, next) => {
res.setHeader('Content‑Security‑Policy', "default-src 'self'; script-src 'self'; object-src 'none'");
next();
});
Monitoring & Observability: Logging Suspicious Token Activity
Instrument the BFF with structured logs (JSON) that capture:
- Token
jtivalues on each refresh - Deny‑list hits (potential replay)
- Unusual IP or User‑Agent changes
Ship logs to a central Loki/Prometheus stack and set alerts for spikes in revokedRefresh look‑ups.
“Studies of real‑world breaches show that over 60 % of SPA security incidents involve stolen tokens, often due to improper storage or missing refresh token rotation.” – Snyk State of Open Source Security Report, 2023
For zero‑downtime rollouts of the BFF, consult our Zero‑Downtime Deployments with GitOps & ArgoCD for Node.js APIs guide.
Common Anti‑Patterns & Architectural Trade‑offs
The localStorage Fallacy: Why It’s a Security Anti‑Pattern
Storing an access token in localStorage makes it trivially readable by any malicious script. Even tight CSP cannot guarantee that a newly injected script won’t run.
Scalability vs. Security: The Cost of Statelessness
Stateless JWTs scale well because they avoid server‑side session stores, but you pay with weaker immediate revocation. Adding a Redis deny‑list introduces a small stateful component, yet it remains horizontally scalable.
When to Consider an Alternative: PASETO, Secure Cookies, or Backend Sessions
If your threat model mandates zero token replay risk, you might switch to PASETO (protocol‑agnostic tokens without signatures) or revert to classic backend sessions stored in a distributed cache. Each choice brings its own operational overhead.
My take: For most modern SaaS products, the BFF + httpOnly‑cookie refresh token pattern hits the sweet spot—minimal client complexity, strong XSS defenses, and graceful scalability.
Common Errors & Fixes
| Symptom | Why it Happens | Fix |
|---|---|---|
401 Unauthorized after page refresh | Access token stored only in memory, lost on reload | Ensure the BFF’s /auth/refresh runs on app mount to silently reacquire a token. |
403 Forbidden on refresh call | Refresh token replay detected (token already blacklisted) | Debounce concurrent refresh calls; use a single‑tab lock or Mutex (e.g., localStorage flag) before calling the endpoint. |
CSRF error despite SameSite=Strict | Browser sends cookie on cross‑origin navigation due to older user agents | Add an explicit CSRF double‑submit token in request bodies for all state‑changing APIs. |
| High latency on token revocation checks | Redis instance under‑provisioned for deny‑list reads | Scale Redis horizontally and enable TTL on the deny‑list set to keep the keyspace small. |
Frequently asked questions
Can I securely store JWTs in localStorage?
No, storing access tokens in localStorage is highly discouraged. localStorage is accessible via JavaScript, making it vulnerable to Cross‑Site Scripting (XSS) attacks. A successful XSS can steal the token, leading to complete account compromise. For SPAs, consider storing refresh tokens in httpOnly cookies (mitigating XSS) and keeping short‑lived access tokens in JavaScript memory.
How do I revoke a JWT before it expires?
JWTs are stateless, so revocation is complex. Common strategies include: 1) Keeping token lifetimes very short (5‑15 mins), minimizing the revocation window. 2) Maintaining a small, fast denylist (e.g., in Redis) for revoked refresh tokens only. 3) Changing the secret key (nuclear option, logs out all users). For access tokens, short expiry combined with a secure refresh flow is the standard approach.
What’s the advantage of using httpOnly cookies for refresh tokens?
httpOnly cookies are not accessible via JavaScript, making them immune to XSS theft. When paired with the `SameSite=Strict` attribute, they also offer strong CSRF protection. This makes them ideal for storing the more powerful refresh token, while the short‑lived access token, which is used frequently, can be managed in the frontend’s runtime memory.
If you’ve stumbled upon a tricky token‑rotation bug or have a preferred BFF library, drop a comment below. Sharing your experience helps the community tighten SPA security, and don’t forget to spread the word on social channels!