Accueil / Articles

Mettre en œuvre les tokens d’accès et de renouvellement ensemble dans Node.js

Tokens d’accès à courte durée de vie associés à des tokens de renouvellement rotatifs en Node.js : le flux JWT, l’endroit où stocker chaque token, et la manière dont la rotation permet de détecter les tokens de renouvellement volés.

Éditeur : REACTAPP.TOP11 min de lecture2600 mots

Sources: Access Tokens vs Refresh Tokens in Node.js: Why Real Applications Need Both

Lorsque vous créez votre premier flux d’authentification en Node.js, l’approche typique consiste à utiliser un seul jeton JSON Web (JWT). L’utilisateur soumet son e-mail et son mot de passe, le serveur les vérifie, signe un jeton et le lui renvoie. À partir de ce moment-là, l’interface utilisateur inclut ce jeton dans l’en-tête Authorization de chaque requête.

Cette configuration semble fonctionner correctement pendant les tests sur localhost. Mais dès que des utilisateurs réels commencent à utiliser l’application, une tension structurelle apparaît.

Fixez la durée de vie du jeton à une valeur courte, comme 15 minutes, et les utilisateurs seront déconnectés au milieu de leur tâche — peut-être en train de taper un commentaire ou à mi-chemin dans un formulaire. Si vous la fixez sur une valeur longue, comme 30 jours, les utilisateurs restent connectés, mais cela crée également une grave faille de sécurité : si ce jeton est volé, il n’est pas possible de le révoquer sans invalider tous les jetons de tous les utilisateurs dans l’ensemble du système.

Cette tension entre la commodité pour les utilisateurs et la sécurité est précisément la raison pour laquelle les systèmes de niveau production ne s’appuient pas sur un seul jeton — ils en utilisent deux : un jeton d’accès et un jeton de renouvellement.

Examinons pourquoi ce modèle à deux jetons existe, quel rôle joue chaque jeton, et comment le mettre en place correctement dans un backend Node.js.

Le problème fondamental de l’authentification à un seul jeton

sans état.

Token payload + Secret key = Signature verified?
├── Yes -> Request allowed
└── No  -> 401 Unauthorized

une fois qu’un jeton a été émis, le serveur ne dispose d’aucun moyen simple pour le rendre invalide précocement.

  • Votre JWT unique est configuré pour durer 30 jours.
  • Un attaquant s’en empare — peut-être grâce à une attaque XSS ou en interceptant le trafic sur un réseau non sécurisé.
  • Le propriétaire du compte remarque qu’il y a un problème et change son mot de passe.
  • L’attaquant continue à agir comme si rien ne s’était passé. Le jeton volé reste valide en théorie jusqu’à l’expiration de sa durée de vie de 30 jours, quel que soit le changement de mot de passe.
  • Une solution consiste à maintenir une liste noire des jetons annulés dans un système comme Redis, mais vérifier cette liste à chaque appel API revient en fait à transformer votre système de jetons « sans état » en un système à état.

    C’est précisément le problème que la méthode à deux jetons est conçue pour résoudre.

    Qu’est-ce que les jetons d’accès et les jetons de renouvellement ?

    Au lieu de faire en sorte qu’un seul jeton soit responsable à la fois de l’accès aux API courants et de la persistance de la connexion sur le long terme, la charge de travail est répartie entre deux jetons :

    +------------------+-----------------------------+-----------------------------+
    | Feature          | Access Token                | Refresh Token               |
    +------------------+-----------------------------+-----------------------------+
    | Purpose          | Authenticate API requests   | Obtain new access tokens    |
    | Lifetime         | Short (5 to 15 minutes)     | Long (7 to 30 days)         |
    | Storage          | Memory or short-lived state | HttpOnly, Secure Cookie     |
    | Database lookup  | No (purely stateless)       | Yes (checked on refresh)    |
    | Exposure risk    | High (sent on every request)| Low (sent only to /refresh) |
    +------------------+-----------------------------+-----------------------------+
    

    1. Le jeton d’accès

    Pensez au jeton d’accès comme à un sésame à durée de vie courte. Le client l’ajoute aux en-têtes des requêtes API ordinaires — comme GET /api/orders ou POST /api/profile.

    Puisqu’il n’est valable que pendant une courte période (généralement de 10 à 15 minutes), tout dommage potentiel causé par une fuite est limité. Le serveur peut le vérifier uniquement grâce à des contrôles de signature basés sur la CPU, sans avoir recours à la base de données, ce qui permet à l’API de rester réactive.

    2. Le jeton de renouvellement

    Le jeton de renouvellement fonctionne comme un bon de renouvellement à long terme. Il n’est jamais envoyé avec les appels API habituels — sa seule fonction est de communiquer avec une adresse cible dédiée, comme POST /api/auth/refresh.

    Dès que le jeton d’accès à courte durée expire, le client appelle discrètement cette route de renouvellement. Le serveur interroge ensuite la base de données pour vérifier que le compte de l’utilisateur reste actif et autorisé, avant d’émettre un nouveau jeton d’accès.

    Puisque cette demande de renouvellement a lieu environ toutes les 15 minutes et non à chaque interaction de l’utilisateur, l’ajout d’une recherche dans la base de données n’a quasiment aucun impact sur les performances.

    Le cycle de vie de la demande

    Voici comment les trois parties — le client, le serveur et la base de données — interagissent dans le temps :

    Client                             Server                           Database
      │                                  │                                 │
      ├─────── 1. Login (credentials) ──>│                                 │
      │                                  ├─── Verify credentials ─────────>│
      │                                  │<── User record valid ───────────┤
      │                                  ├─── Store Refresh Token Hash ───>│
      │<────── 2. Return Tokens ─────────┤                                 │
      │       (Access: 15m, Refresh: 7d) │                                 │
      │                                  │                                 │
      │─────── 3. GET /api/dashboard ───>│                                 │
      │       [Bearer Access Token]      ├─── Verify signature (Stateless) │
      │<────── 4. 200 OK + Data ─────────┤                                 │
      │                                  │                                 │
      │    ... 15 minutes pass ...       │                                 │
      │                                  │                                 │
      │─────── 5. GET /api/dashboard ───>│                                 │
      │       [Expired Access Token]     ├─── Token expired!               │
      │<────── 6. 401 Unauthorized ──────┤                                 │
      │                                  │                                 │
      │─────── 7. POST /api/auth/refresh>│                                 │
      │       [Cookie: Refresh Token]    ├─── Check if token in DB ───────>│
      │                                  │<── Token valid & not revoked ───┤
      │                                  ├─── Generate new Access Token    │
      │<────── 8. Return new Access Token┤                                 │
      │                                  │                                 │
      │─────── 9. Retry GET /dashboard ─>│                                 │
      │       [New Access Token]         ├─── Verify signature             │
      │<────── 10. 200 OK + Data ────────┤                                 │
    

    Du point de vue de l’utilisateur, aucun aspect de ce processus de renouvellement n’est visible. Sa session continue simplement de fonctionner en arrière-plan, sans que des messages intrusifs tels que « Session expirée » ne perturbent son utilisation en cours.

    Mettre en pratique avec Node.js

    Examinons maintenant comment créer une configuration Express fonctionnelle qui montre comment émettre, vérifier et renouveler des jetons.

    Commencez par installer les paquets requis :

    npm install express jsonwebtoken cookie-parser dotenv
    

    1. Création des jetons

    Mettez en place un module d’aide qui génère à la fois les jetons, chacun signé avec son propre secret :

    // tokenService.js
    const jwt = require('jsonwebtoken');
    
    const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access_secret_123';
    const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh_secret_456';
    
    function generateAccessToken(userId) {
      // Short lifespan: 15 minutes
      return jwt.sign({ sub: userId }, ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
    }
    
    function generateRefreshToken(userId) {
      // Long lifespan: 7 days
      return jwt.sign({ sub: userId }, REFRESH_TOKEN_SECRET, { expiresIn: '7d' });
    }
    
    module.exports = {
      generateAccessToken,
      generateRefreshToken,
      ACCESS_TOKEN_SECRET,
      REFRESH_TOKEN_SECRET
    };
    

    Point clé : attribuez toujours au jeton d’accès et au jeton de renouvellement des clés secrètes distinctes. S’ils partagent la même clé secrète, quelqu’un pourrait utiliser un jeton de renouvellement expiré et le présenter là où un jeton d’accès est attendu, contournant ainsi l’expiration que vous souhaitez imposer.

    2. Gestion de la connexion et enregistrement du jeton de renouvellement

    Le route de connexion doit stocker le jeton de renouvellement — ou mieux, son hash SHA-256 — dans votre base de données, associé à cet utilisateur, afin de pouvoir le retrouver ultérieurement :

    // server.js
    const express = require('express');
    const cookieParser = require('cookie-parser');
    const jwt = require('jsonwebtoken');
    const {
      generateAccessToken,
      generateRefreshToken,
      ACCESS_TOKEN_SECRET,
      REFRESH_TOKEN_SECRET
    } = require('./tokenService');
    
    const app = express();
    app.use(express.json());
    app.use(cookieParser());
    
    // Simulated database table for refresh tokens
    const refreshTokenDatabase = new Set();
    
    app.post('/api/auth/login', async (req, res) => {
      const { email, password } = req.body;
    
      // In a real app: verify email and hashed password from DB
      const mockUserId = 'usr_987654';
    
      const accessToken = generateAccessToken(mockUserId);
      const refreshToken = generateRefreshToken(mockUserId);
    
      // Save the refresh token to our database so we can revoke it later
      refreshTokenDatabase.add(refreshToken);
    
      // Send refresh token inside an HttpOnly cookie
      res.cookie('refreshToken', refreshToken, {
        httpOnly: true,     // Prevents JavaScript from reading the cookie (protects from XSS)
        secure: process.env.NODE_ENV === 'production', // Requires HTTPS
        sameSite: 'strict', // Protects against CSRF attacks
        maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
      });
    
      // Send access token in JSON body for frontend memory storage
      res.json({ accessToken });
    });
    

    3. Protection des points d’entrée métier

    Une fonction de middleware vérifie le jeton d’accès extrait de l’en-tête Authorization avant d’autoriser la transmission d’une requête :

    function authenticateAccessToken(req, res, next) {
      const authHeader = req.headers['authorization'];
      // The header format is "Bearer <token>"
      const token = authHeader && authHeader.split(' ')[1];
    
      if (!token) {
        return res.status(401).json({ error: 'Access token missing' });
      }
    
      jwt.verify(token, ACCESS_TOKEN_SECRET, (err, decoded) => {
        if (err) {
          // 403 signals to the client that the token is invalid or expired
          return res.status(403).json({ error: 'Invalid or expired access token' });
        }
        req.userId = decoded.sub;
        next();
      });
    }
    
    // Protected route
    app.get('/api/orders', authenticateAccessToken, (req, res) => {
      res.json({
        message: `Orders retrieved for user ${req.userId}`,
        data: [{ id: 1, item: 'Mechanical Keyboard' }]
      });
    });
    

    4. Création de la route de renouvellement

    Lorsque le client reçoit une réponse 403 suite à une requête telle que /api/orders, il se tourne vers ce point d’entrée pour obtenir un nouveau jeton d’accès :

    app.post('/api/auth/refresh', (req, res) => {
      const refreshToken = req.cookies.refreshToken;
    
      if (!refreshToken) {
        return res.status(401).json({ error: 'Refresh token missing' });
      }
    
      // Check if token exists in the database
      if (!refreshTokenDatabase.has(refreshToken)) {
        return res.status(403).json({ error: 'Refresh token revoked or unknown' });
      }
    
      jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, decoded) => {
        if (err) {
          // Remove invalid token from database
          refreshTokenDatabase.delete(refreshToken);
          return res.status(403).json({ error: 'Refresh token expired' });
        }
    
        // Token is valid! Issue a new access token
        const newAccessToken = generateAccessToken(decoded.sub);
        res.json({ accessToken: newAccessToken });
      });
    });
    

    5. Déconnexion et révocation de l’accès

    Il est simple de déconnecter un utilisateur : il suffit de supprimer le jeton de renouvellement de votre base de données et d’effacer le cookie qui le contient. Le jeton d’accès ne nécessite aucune manipulation particulière — il expirera simplement de lui-même en quelques minutes.

    app.post('/api/auth/logout', (req, res) => {
      const refreshToken = req.cookies.refreshToken;
    
      if (refreshToken) {
        refreshTokenDatabase.delete(refreshToken);
      }
    
      res.clearCookie('refreshToken');
      res.json({ message: 'Logged out successfully' });
    });
    

    Où les jetons doivent-ils être stockés du côté client ?

    Une question qui se pose constamment est de savoir où le frontend doit conserver ces jetons.

    Une erreur fréquente consiste à stocker les deux types de jetons dans localStorage.

    Le problème, c’est que tout script exécuté sur votre page — qu’il provienne d’une dépendance npm compromise ou d’un tag inséré en ligne de commande — peut lire localStorage simplement en utilisant localStorage.getItem('token').

    Un modèle plus sûr ressemble à ceci :

    • Gardez le jeton d’accès uniquement en mémoire. Stockez-le dans l’état de fonctionnement de votre application — React Context, un store Pinia, ou même une simple variable. Comme il expire rapidement et disparaît dès que la fenêtre se ferme, même une injection de script réussie ne crée qu’une courte période de risque.
    • Gardez le jeton de renouvellement dans un cookie HttpOnly. JavaScript n’a absolument aucun accès aux cookies HttpOnly. Le navigateur les ajoute automatiquement chaque fois que le client appelle /api/auth/refresh, ce qui signifie que vos identifiants à longue durée de vie ne touchent jamais de code que quelqu’un d’autre pourrait contrôler.

    Renforcer la sécurité par la rotation des jetons de renouvellement

    Même avec la configuration décrite ci-dessus, un schéma de jeton de renouvellement simple présente une faiblesse persistante : si un attaquant parvient à s’emparer du jeton de renouvellement — que ce soit via le stockage des cookies ou en interceptant le trafic — il peut continuer à générer de nouveaux jetons d’accès tant que ce dernier reste valide, potentiellement pendant toute la période de 7 jours.

    Les systèmes en production comblent cette lacune grâce à la rotation des jetons de renouvellement.

    Au cours de cette rotation :

    • Chaque fois qu’un jeton de renouvellement est utilisé pour obtenir un nouveau jeton d’accès, le serveur supprime l’ancien jeton de renouvellement et en remet un entièrement nouveau.
    • Les jetons d’accès et de renouvellement sont remplacés ensemble, demande après demande.
    Request 1: Send Refresh Token A -> Receive Access Token 2 + Refresh Token B (Token A invalidated)
    Request 2: Send Refresh Token B -> Receive Access Token 3 + Refresh Token C (Token B invalidated)
    

    Détecter automatiquement les violations de sécurité

    La rotation des jetons de renouvellement permet d’activer une protection puissante appelée détection de réutilisation.

    Imaginez un scénario où un attaquant parvient à copier le Refresh Token A.

    • L’utilisateur légitime réclame d’abord le Refresh Token A, et le serveur répond en lui fournissant le Refresh Token B.
    • Quelque temps plus tard, l’attaquant tente de réclamer le Refresh Token A, désormais obsolète.
    • Le serveur vérifie ses archives, constate que le Refresh Token A a déjà été utilisé, et signale cela comme un signe de compromission.
    • Il répond en annulant toute la chaîne de tokens, y compris le Refresh Token B, ce qui force à la fois l’attaquant et l’utilisateur légitime à retourner à l’écran de connexion.

    Au lieu qu’un attaquant continue ses actions en silence en arrière-plan, le système réagit immédiatement et bloque tout le monde jusqu’à ce qu’une nouvelle authentification ait lieu.

    Péchés à éviter

    Faites attention à ces quatre erreurs fréquentes :

    • Inclusion d’informations sensibles dans le jeton d’accès. Le chargement d’un JWT est uniquement codé en Base64, et non chiffré, ce qui permet à quiconque l’intercepte de lire son contenu directement. Évitez absolument d’inclure des mots de passe, des numéros de carte et autres informations privées dans ce chargement.
    • Permettre à les enregistrements de jetons de renouvellement d’accumuler indéfiniment. Sans colonne de date d’expiration ni routine de nettoyage, votre table des jetons de renouvellement continuera de s’agrandir sans limite. Ajoutez un champ expires_at et planifiez une tâche qui supprime périodiquement les entrées expirées.
  • Négligence des conditions de concurrence du côté client. Lorsque plusieurs appels API sont lancés en même temps et que le jeton d’accès a déjà expiré, ils rencontreront tous simultanément l’erreur 403. Si votre interface utilisateur envoie alors plusieurs appels /refresh parallèles en réponse, la logique de rotation interprétera cela comme une réutilisation et mettra fin à la session. Pour éviter ce problème, placez les requêtes sortantes en file d’attente derrière une seule demande de renouvellement en cours.
  • Situations où un seul jeton peut suffire

    Cette approche à deux jetons est la solution standard pour les clients web et mobiles modernes, mais elle n’est pas obligatoire partout :

    • Trafic interne entre services : les machines qui communiquent entre elles reposent généralement sur des flux de crédenceaux clients, des jetons à durée de vie courte ou du TLS mutuel, qui n’ont pas besoin d’un cycle de renouvellement interactif.
    • Applications de base générées côté serveur : pour une application traditionnelle générée du côté serveur — disons, une construite avec des templates Express classiques — un simple cookie de session soutenu par un stockage en mémoire comme Redis est généralement plus facile à gérer et offre une sécurité comparable.

    Préférez le schéma des tokens d’accès et de mise à jour lorsque vous développez des applications single-page (React, Vue ou clients mobiles) qui communiquent avec des backends REST ou GraphQL sans état.

    Conclusion

    Se fier à un seul JWT pour tout impose un compromis inutile : soit garder les sessions courtes et ennuyer les utilisateurs avec des connexions fréquentes, soit les garder longues et s’exposer à de graves risques de sécurité.

    Diviser l’authentification en deux tokens résout ce conflit :

    • Tokens d’accès : ils rendent chaque requête rapide et sans état, épargnant ainsi votre base de données des recherches constantes.
    • Tokens de renouvellement : ils gèrent de manière sécurisée la persistance à long terme des sessions, vous permettant d’annuler instantanément les sessions compromises.

    Investir du temps pour configurer correctement ces deux types de tokens vous assure une expérience fluide pour les utilisateurs et un système mieux protégé contre les problèmes de sécurité abordés ici.