Le jeton JWT (JSON Web Token) est devenu le standard de fait pour l’authentification sans état dans les API modernes. En 2026, la bibliothèque jsonwebtoken reste la référence côté Node.js avec sa version stable 9.0.3, que Snyk classe sans vulnérabilité connue. Ce tutoriel vous guide en 12 étapes pour construire un système d’authentification JWT complet, sécurisé et prêt pour la production, avec access token, refresh token, hachage de mot de passe et middleware de vérification.

Comptez environ 45 minutes pour le suivre de bout en bout. À la fin, vous disposerez d’un projet Express fonctionnel, d’une compréhension claire des algorithmes HS256, RS256 et ES256, et d’une liste de pièges à éviter qui font tomber la majorité des implémentations amateurs. Tout le code est testé sur Node.js 22 LTS.

JWT en 2026 : pourquoi l’authentification par jeton domine

Un JWT transporte des informations vérifiables entre deux parties sous forme d’objet JSON signé. Contrairement aux sessions classiques, le serveur n’a rien à stocker : le jeton lui-même contient les revendications (claims) sur l’utilisateur, et la signature garantit qu’il n’a pas été falsifié. Cette nature sans état explique son succès dans les architectures distribuées, les microservices et les applications mobiles, où conserver une session côté serveur sur chaque nœud coûte cher.

La spécification officielle, la RFC 7519, définit la structure du jeton. Une seconde RFC, la RFC 8725 (JWT Best Current Practices), publiée par l’IETF, énumère les contre-mesures contre les attaques connues. Lire ces deux documents change la façon dont vous écrivez votre code : on comprend vite que JWT n’est pas magique et qu’une mauvaise configuration ouvre des failles graves.

Le débat JWT contre sessions revient sans cesse. Les sessions sont avec état : le serveur garde une trace de chaque connexion, ce qui rend la révocation immédiate triviale. Les JWT sont sans état : ils passent à l’échelle sans base de données de sessions, mais révoquer un jeton avant son expiration demande un effort supplémentaire (liste de révocation, rotation des refresh tokens). Ce tutoriel adopte une approche hybride qui combine le meilleur des deux mondes.

Un point essentiel souvent mal compris : la charge utile d’un JWT n’est pas chiffrée. Elle est seulement encodée en Base64URL, donc lisible par quiconque intercepte le jeton. La signature empêche la modification, pas la lecture. Ne placez jamais de mot de passe, de numéro de carte ou de donnée sensible dans le payload. Si vous avez besoin de confidentialité, il faut chiffrer le jeton (JWE), un sujet que nous abordons dans les astuces avancées.

Prérequis et versions logicielles requises

Avant de coder, vérifiez votre environnement. Les versions ci-dessous sont celles testées pour ce guide. Pour les composants où une version exacte n’est pas critique, la dernière version stable convient parfaitement.

ComposantVersion recommandéeRôle dans le projet
Node.js22 LTS (ou dernière LTS)Environnement d’exécution serveur
npm10 ou supérieurGestionnaire de paquets
jsonwebtoken9.0.3Signature et vérification des JWT
expressdernière version 4.x ou 5.xServeur HTTP et routage
bcryptdernière version stableHachage des mots de passe
argon2dernière version stableAlternative moderne à bcrypt
dotenvdernière version stableGestion des variables d’environnement
cookie-parserdernière version stableLecture des cookies httpOnly

Vérifiez votre version de Node.js avec une seule commande. Si elle est inférieure à 18, mettez à jour : les versions plus anciennes manquent du module crypto.webcrypto stable et de plusieurs correctifs de sécurité. La liste officielle des versions LTS se trouve sur nodejs.org.

node --version
# Sortie attendue : v22.x.x (ou une version LTS plus récente)

npm --version
# Sortie attendue : 10.x.x ou supérieur

Côté connaissances, vous devez être à l’aise avec JavaScript asynchrone (async/await), les requêtes HTTP et la notion de middleware Express. Une compréhension de base des signatures numériques et des fonctions de hachage cryptographiques aide énormément, car JWT repose entièrement sur ces deux primitives.

Anatomie d’un JWT : header, payload et signature

Un JWT se compose de trois segments séparés par des points : header.payload.signature. Chaque segment est encodé en Base64URL. Un jeton réel ressemble à ceci (raccourci pour la lisibilité) :

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3ODA1MDAwMDB9.dQw4w9WgXcQ_signature_tronquee

Le header : type et algorithme

Le premier segment décrit le type du jeton et l’algorithme de signature. Décodé, il donne un objet JSON minimal. Le champ alg est critique pour la sécurité : c’est précisément ce champ que les attaquants tentent de manipuler avec l’attaque alg: none, dont nous parlerons plus loin.

{
  "alg": "HS256",
  "typ": "JWT"
}

Le payload : les revendications (claims)

Le deuxième segment contient les claims. On distingue les claims enregistrés, standardisés par la RFC 7519, et les claims privés que vous définissez. Les claims enregistrés les plus utiles sont exp (expiration), iat (date d’émission), sub (sujet), iss (émetteur) et aud (audience). Point crucial : la bibliothèque jsonwebtoken n’applique aucune valeur par défaut pour expiresIn, audience, issuer ou subject. Si vous ne les définissez pas, vos jetons n’expirent jamais.

{
  "sub": "123",
  "role": "admin",
  "iat": 1780496400,
  "exp": 1780500000
}

La signature : le verrou cryptographique

Le troisième segment est calculé en signant header.payload avec une clé secrète (HS256) ou une clé privée (RS256, ES256). Le serveur recalcule cette signature à chaque requête pour vérifier que le jeton n’a pas été modifié. Changez un seul caractère du payload et la signature ne correspond plus : la vérification échoue. C’est exactement le principe des signatures numériques appliqué à un format compact destiné au web.

HS256 vs RS256 vs ES256 : choisir le bon algorithme

Le choix de l’algorithme conditionne l’architecture entière. HS256 est symétrique : la même clé signe et vérifie. RS256 et ES256 sont asymétriques : une clé privée signe, une clé publique vérifie. Ce détail change tout dès que plusieurs services doivent valider le même jeton.

CritèreHS256RS256ES256
TypeSymétrique (HMAC-SHA256)Asymétrique (RSA)Asymétrique (ECDSA P-256)
Clé de signatureSecret partagéClé privée RSAClé privée elliptique
Clé de vérificationMême secretClé publique RSAClé publique elliptique
Taille de signaturePetiteGrande (256 octets pour RSA-2048)Compacte (64 octets)
Cas d’usage idéalService unique, monolitheMicroservices, OIDCMobile, IoT, performances
Coût de vérificationTrès faibleÉlevéModéré

Règle pratique : si une seule application signe et vérifie ses propres jetons, HS256 suffit et reste le plus simple. Dès que plusieurs services indépendants doivent vérifier les jetons sans partager de secret, passez à RS256 ou ES256. La clé publique se distribue librement, la clé privée reste sur le service d’authentification. ES256 produit des signatures plus compactes et des vérifications plus rapides que RSA, ce qui en fait un excellent choix pour les contextes contraints. Pour approfondir la cryptographie à courbes elliptiques, consultez notre guide sur les signatures numériques ECDSA et EdDSA.

Étape 1 : initialiser le projet Node.js

Créez un dossier de projet et initialisez-le avec npm. Nous utilisons les modules ES (type module) pour bénéficier de la syntaxe import moderne, désormais standard en 2026.

mkdir jwt-auth-api && cd jwt-auth-api
npm init -y
npm pkg set type="module"

Installez ensuite les dépendances. Nous épinglons jsonwebtoken à la version 9.0.3, validée sans vulnérabilité connue, et ajoutons Express, bcrypt, dotenv et cookie-parser.

npm install express [email protected] bcrypt dotenv cookie-parser
# Vérifiez l'installation
npm list jsonwebtoken
# [email protected]
# └── [email protected]

Étape 2 : générer et stocker un secret robuste

La sécurité de HS256 dépend entièrement de la force du secret. Un secret faible comme "secret" ou "123456" se casse par force brute en quelques secondes avec des outils comme hashcat. Générez un secret aléatoire d’au moins 256 bits avec le module crypto intégré.

node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# Exemple de sortie :
# 9f2c8a1e4b7d3f6a0c5e8b2d1f4a7c9e3b6d0f8a2c5e7b1d4f9a3c6e8b0d2f5a7c1e4b6d8

Copiez cette valeur dans un fichier .env que vous ajoutez immédiatement à .gitignore. Ne committez jamais un secret dans Git : c’est l’une des fuites les plus fréquentes signalées dans les analyses de fuites de données. Prévoyez deux secrets distincts, un pour les access tokens et un pour les refresh tokens.

# Fichier .env
ACCESS_TOKEN_SECRET=9f2c8a1e4b7d3f6a0c5e8b2d1f4a7c9e3b6d0f8a2c5e7b1d4f9a3c6e8b0d2f5a
REFRESH_TOKEN_SECRET=a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0fedcba9876543210
PORT=3000

Étape 3 : hacher les mots de passe avec bcrypt

JWT gère la session, pas les mots de passe. Avant d’émettre le moindre jeton, hachez le mot de passe de l’utilisateur. N’utilisez jamais SHA-256 seul ni un stockage en clair : seuls des algorithmes lents et résistants au matériel dédié conviennent. bcrypt reste un choix solide, avec un facteur de coût (rounds) configurable. Pour une sécurité de pointe, argon2id est encore meilleur, comme détaillé dans notre guide dédié au hachage Argon2 en Node.js.

// auth/password.js
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12;

export async function hashPassword(plain) {
  return bcrypt.hash(plain, SALT_ROUNDS);
}

export async function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}

Un facteur de coût de 12 offre un bon équilibre en 2026 : assez lent pour décourager les attaques par force brute, assez rapide pour ne pas bloquer le serveur sur chaque connexion. Mesurez le temps de hachage sur votre matériel et visez environ 250 ms par opération. Si vos serveurs sont plus puissants, augmentez à 13 ou 14.

Étape 4 : créer la fonction de génération d’access token

Place au cœur du sujet. La fonction signAccessToken encapsule l’appel à jwt.sign. Notez les options explicites : expiresIn court (15 minutes), issuer et audience définis. Ces deux derniers claims permettront de rejeter un jeton émis par un autre service.

// auth/tokens.js
import jwt from 'jsonwebtoken';
import 'dotenv/config';

const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET;

export function signAccessToken(user) {
  return jwt.sign(
    { role: user.role },
    ACCESS_SECRET,
    {
      subject: String(user.id),
      expiresIn: '15m',
      issuer: 'jwt-auth-api',
      audience: 'jwt-auth-client',
      algorithm: 'HS256',
    }
  );
}

Évitez d’entasser des données dans le payload. Plus le jeton est gros, plus il alourdit chaque requête HTTP. Mettez l’identifiant dans sub, le rôle si nécessaire, et rien d’autre. Toute donnée modifiable (nom, e-mail) doit être rechargée depuis la base à partir de l’identifiant, sinon elle devient obsolète dès que l’utilisateur la change.

Étape 5 : générer un refresh token séparé

L’access token est volontairement éphémère pour limiter les dégâts en cas de vol. Le refresh token, à durée plus longue, permet d’obtenir un nouvel access token sans redemander le mot de passe. Il utilise un secret différent et une durée de vie plus longue, par exemple 7 jours. La documentation de jsonwebtoken avertit que le rafraîchissement automatique mal conçu introduit des failles : implémentez-le avec soin.

export function signRefreshToken(user, tokenId) {
  return jwt.sign(
    { tokenId },
    REFRESH_SECRET,
    {
      subject: String(user.id),
      expiresIn: '7d',
      issuer: 'jwt-auth-api',
      audience: 'jwt-auth-client',
      algorithm: 'HS256',
    }
  );
}

Le champ tokenId est la clé de la révocation. Stockez cet identifiant côté serveur (base de données ou Redis). Lors d’un rafraîchissement, vérifiez que le tokenId existe toujours et n’a pas été invalidé. Cette approche réintroduit juste assez d’état pour permettre une déconnexion réelle, sans sacrifier la scalabilité de l’access token.

Type de jetonDurée recommandéeStockage côté clientSecret utilisé
Access token5 à 15 minutesMémoire JavaScriptACCESS_TOKEN_SECRET
Refresh token7 à 30 joursCookie httpOnly + SecureREFRESH_TOKEN_SECRET
Jeton de réinitialisation15 à 60 minutesJamais persisté côté clientSecret dédié
Jeton de vérification e-mail24 heuresLien à usage uniqueSecret dédié

Étape 6 : construire le serveur Express et la route de connexion

Assemblons maintenant le serveur. Pour ce tutoriel, nous simulons une base de données avec un tableau en mémoire. En production, remplacez-le par PostgreSQL, MongoDB ou tout autre stockage. La route /login vérifie le mot de passe haché, puis émet les deux jetons.

// server.js
import express from 'express';
import cookieParser from 'cookie-parser';
import crypto from 'crypto';
import 'dotenv/config';
import { hashPassword, verifyPassword } from './auth/password.js';
import { signAccessToken, signRefreshToken } from './auth/tokens.js';

const app = express();
app.use(express.json());
app.use(cookieParser());

// Base de donnees simulee
const users = [];
const validRefreshTokens = new Set();

// Inscription
app.post('/register', async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password) {
    return res.status(400).json({ error: 'Champs manquants' });
  }
  if (users.find(u => u.email === email)) {
    return res.status(409).json({ error: 'Utilisateur existant' });
  }
  const hash = await hashPassword(password);
  const user = { id: users.length + 1, email, passwordHash: hash, role: 'user' };
  users.push(user);
  res.status(201).json({ id: user.id, email: user.email });
});

La route de connexion suit la même logique mais émet les jetons. Le refresh token part dans un cookie httpOnly, inaccessible au JavaScript du navigateur, ce qui le protège du vol par XSS. L’access token est renvoyé dans le corps de la réponse, à conserver en mémoire côté client.

// Connexion
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: 'Identifiants invalides' });
  }
  const ok = await verifyPassword(password, user.passwordHash);
  if (!ok) {
    return res.status(401).json({ error: 'Identifiants invalides' });
  }

  const tokenId = crypto.randomUUID();
  validRefreshTokens.add(tokenId);

  const accessToken = signAccessToken(user);
  const refreshToken = signRefreshToken(user, tokenId);

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });

  res.json({ accessToken });
});

Étape 7 : écrire le middleware de vérification

Le middleware protège les routes privées. Il extrait le jeton de l’en-tête Authorization: Bearer, le vérifie, et attache l’utilisateur décodé à la requête. Point capital de sécurité : passez explicitement l’option algorithms: ['HS256']. Sans cette liste blanche, un attaquant peut tenter de basculer l’algorithme et contourner la vérification.

// auth/middleware.js
import jwt from 'jsonwebtoken';
import 'dotenv/config';

export function authenticate(req, res, next) {
  const header = req.headers['authorization'];
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Jeton absent' });
  }
  const token = header.split(' ')[1];

  try {
    const payload = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, {
      algorithms: ['HS256'],
      issuer: 'jwt-auth-api',
      audience: 'jwt-auth-client',
    });
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Jeton expire' });
    }
    return res.status(403).json({ error: 'Jeton invalide' });
  }
}

Distinguez bien les codes d’erreur. Un TokenExpiredError renvoie 401 pour signaler au client qu’il doit rafraîchir son jeton. Une signature invalide renvoie 403 : le jeton est falsifié, il n’y a rien à rafraîchir. Cette distinction permet au client de réagir intelligemment plutôt que de déconnecter l’utilisateur au moindre incident.

Étape 8 : protéger une route et tester le flux

Ajoutez une route protégée qui renvoie le profil de l’utilisateur connecté. Le middleware authenticate s’exécute avant le gestionnaire.

import { authenticate } from './auth/middleware.js';

app.get('/me', authenticate, (req, res) => {
  const user = users.find(u => u.id === Number(req.user.id));
  if (!user) return res.status(404).json({ error: 'Introuvable' });
  res.json({ id: user.id, email: user.email, role: user.role });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API sur le port ${PORT}`));

Testez le flux complet avec curl. Inscrivez un utilisateur, connectez-vous, récupérez l’access token, puis appelez la route protégée.

# 1. Inscription
curl -X POST http://localhost:3000/register \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"MotDePasseFort!2026"}'

# 2. Connexion (recupere l'access token)
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"MotDePasseFort!2026"}'
# Sortie : {"accessToken":"eyJhbGciOiJIUzI1NiI..."}

# 3. Route protegee
curl http://localhost:3000/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiI..."
# Sortie : {"id":1,"email":"[email protected]","role":"user"}

Étape 9 : implémenter la rotation des refresh tokens

La rotation est la meilleure défense contre le vol de refresh token. À chaque rafraîchissement, on invalide l’ancien tokenId et on en émet un nouveau. Si un jeton volé est réutilisé après rotation, le serveur détecte la réutilisation et révoque toute la chaîne, forçant une reconnexion. Pour la théorie, consultez l’analyse d’Auth0 sur les refresh tokens.

app.post('/refresh', (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ error: 'Pas de refresh token' });

  try {
    const payload = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, {
      algorithms: ['HS256'],
      issuer: 'jwt-auth-api',
      audience: 'jwt-auth-client',
    });

    // Detection de reutilisation
    if (!validRefreshTokens.has(payload.tokenId)) {
      return res.status(403).json({ error: 'Refresh token revoque' });
    }

    // Rotation : on invalide l'ancien, on emet un nouveau
    validRefreshTokens.delete(payload.tokenId);
    const newTokenId = crypto.randomUUID();
    validRefreshTokens.add(newTokenId);

    const user = users.find(u => u.id === Number(payload.sub));
    const accessToken = signAccessToken(user);
    const refreshToken = signRefreshToken(user, newTokenId);

    res.cookie('refreshToken', refreshToken, {
      httpOnly: true, secure: true, sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,
    });
    res.json({ accessToken });
  } catch (err) {
    return res.status(403).json({ error: 'Refresh token invalide' });
  }
});

Étape 10 : gérer la déconnexion et la révocation

Comme les JWT sont sans état, un access token reste valide jusqu’à son expiration même après déconnexion. C’est pourquoi on garde l’access token court (15 minutes maximum). La déconnexion consiste à supprimer le refresh token côté serveur et à effacer le cookie côté client. L’access token restant expirera de lui-même en quelques minutes.

app.post('/logout', (req, res) => {
  const token = req.cookies.refreshToken;
  if (token) {
    try {
      const payload = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, {
        algorithms: ['HS256'],
      });
      validRefreshTokens.delete(payload.tokenId);
    } catch (_) { /* jeton deja invalide, on ignore */ }
  }
  res.clearCookie('refreshToken');
  res.json({ message: 'Deconnexion reussie' });
});

Pour une révocation immédiate des access tokens (cas d’un compte compromis), maintenez une liste de révocation (denylist) en Redis, indexée par sub ou par identifiant de jeton, avec une expiration automatique calée sur la durée de l’access token. Le middleware consulte cette liste à chaque requête. C’est un compromis : on perd un peu de l’avantage sans état, mais on gagne une révocation instantanée.

Le stockage côté client divise la communauté depuis des années. Voici la position défendable en 2026, alignée sur les bonnes pratiques de l’OWASP JWT Cheat Sheet.

  • localStorage : facile à utiliser, mais exposé au XSS. Tout script injecté dans la page peut lire le jeton. À éviter pour les jetons sensibles.
  • Cookie httpOnly + Secure + SameSite : invisible au JavaScript, donc protégé du XSS, mais exposé au CSRF si mal configuré. L’attribut SameSite=strict neutralise la plupart des attaques CSRF.
  • Mémoire JavaScript (variable) : l’access token vit dans une variable, jamais persisté. Disparaît au rafraîchissement de la page, mais c’est l’option la plus sûre contre le vol persistant.

L’architecture recommandée : access token en mémoire, refresh token en cookie httpOnly. Au chargement de la page, le client appelle /refresh pour obtenir un nouvel access token à partir du cookie. Cette combinaison résiste à la fois au XSS (le refresh token est inaccessible au script) et au vol persistant (l’access token disparaît à la fermeture de l’onglet). Servez impérativement le tout en HTTPS, comme expliqué dans notre article sur HTTPS et TLS.

Étape 12 : le projet complet assemblé

Voici la structure finale du projet. Chaque responsabilité est isolée dans son module, ce qui facilite les tests et la maintenance.

jwt-auth-api/
├── .env                 # secrets (jamais commite)
├── .gitignore           # ignore .env et node_modules
├── package.json
├── server.js            # serveur Express et routes
└── auth/
    ├── password.js      # hachage bcrypt
    ├── tokens.js        # signature des JWT
    └── middleware.js    # verification des JWT

Lancez le serveur avec node server.js. Le flux complet fonctionne : inscription, connexion, accès protégé, rafraîchissement, déconnexion. Pour passer en production, remplacez le tableau users par une vraie base, déplacez validRefreshTokens vers Redis, ajoutez une limitation de débit (rate limiting) sur /login et activez les en-têtes de sécurité avec helmet. Ce squelette respecte déjà l’essentiel des recommandations de la RFC 8725.

Cinq pièges courants qui font tomber votre sécurité JWT

La plupart des failles JWT ne viennent pas de la cryptographie, mais d’erreurs d’implémentation. Voici les cinq plus fréquentes et comment les éviter.

Piège 1 : ne pas verrouiller l’algorithme

L’attaque alg: none exploite les implémentations qui font confiance au champ alg du jeton. L’attaquant le passe à none, supprime la signature, et certaines bibliothèques acceptent le jeton sans vérification. La parade : toujours passer algorithms: ['HS256'] à jwt.verify. C’est non négociable. Une variante consiste à transformer un RS256 en HS256 pour signer avec la clé publique connue : la liste blanche bloque aussi cette attaque.

Piège 2 : utiliser un secret faible ou réutilisé

Un secret court ou prévisible se casse par force brute. Utilisez au minimum 256 bits d’entropie aléatoire, comme à l’étape 2. N’utilisez jamais le même secret pour les access et refresh tokens, ni le même secret entre environnements de développement et de production.

Piège 3 : oublier l’expiration

Rappel critique : jsonwebtoken n’impose aucune expiration par défaut. Un jeton sans expiresIn reste valable pour toujours. S’il est volé, l’attaquant a un accès permanent. Définissez toujours expiresIn explicitement, court pour les access tokens.

Piège 4 : stocker des données sensibles dans le payload

Le payload est encodé, pas chiffré. N’importe qui peut le décoder sur jwt.io. Mots de passe, clés API, données personnelles n’ont rien à y faire. Si vous devez transporter des données confidentielles dans un jeton, utilisez JWE (JSON Web Encryption).

Piège 5 : conserver l’access token dans localStorage

Comme vu à l’étape 11, localStorage est lisible par tout script. Une seule faille XSS et tous vos jetons fuient. Préférez la mémoire pour l’access token et un cookie httpOnly pour le refresh token.

Vulnérabilités JWT et durcissement avancé

Au-delà des cinq pièges, plusieurs vecteurs méritent attention. L’historique de jsonwebtoken recense des correctifs comme la CVE-2022-23529, désormais corrigée dans les versions récentes. Rester sur la 9.0.3, sans vulnérabilité connue selon Snyk, est la première ligne de défense. La sécurité de votre chaîne dépend aussi de vos dépendances : auditez-les régulièrement avec npm audit.

La confusion d’algorithme (algorithm confusion) reste le piège le plus subtil avec les clés asymétriques. Si votre service utilise RS256 et qu’un attaquant force HS256 en signant avec votre clé publique RSA (qui est, par définition, publique), une vérification naïve l’accepte. Encore une fois, la liste blanche d’algorithmes côté verify est la parade. Validez aussi systématiquement iss et aud pour rejeter les jetons d’un autre émetteur ou destinés à une autre audience.

Pensez à la limitation de débit sur les routes d’authentification. Sans elle, /login devient une cible de bourrage d’identifiants (credential stuffing). Couplez JWT avec une politique de mots de passe robuste et idéalement une authentification à deux facteurs. JWT authentifie une session, il ne remplace pas une stratégie d’identité complète. Le guide OWASP Top 10 recense ces vecteurs dans la catégorie des défaillances d’authentification.

VulnérabilitéMécanismeContre-mesure
alg: noneSuppression de la signatureListe blanche d’algorithmes au verify
Confusion d’algorithmeRS256 forcé en HS256Algorithme imposé explicitement
Secret faibleForce brute du secret HMACSecret de 256 bits aléatoire
Absence d’expirationJeton valable indéfinimentexpiresIn court obligatoire
Vol par XSSLecture du jeton en localStorageCookie httpOnly + access en mémoire
Rejeu de refresh tokenRéutilisation d’un jeton voléRotation et détection de réutilisation

Dépannage : huit problèmes JWT fréquents et leurs solutions

Voici les erreurs que vous rencontrerez le plus souvent en développant, avec leur cause et leur résolution.

  • JsonWebTokenError: invalid signature : le secret de vérification ne correspond pas à celui de signature. Vérifiez que .env est bien chargé et que vous n’utilisez pas le secret du refresh token pour vérifier un access token.
  • TokenExpiredError: jwt expired : comportement normal, l’access token a dépassé sa durée de vie. Le client doit appeler /refresh. Ne traitez pas cela comme une erreur fatale.
  • JsonWebTokenError: jwt malformed : le jeton transmis n’a pas le format a.b.c. Vérifiez que vous extrayez bien la partie après Bearer sans inclure le mot-clé.
  • JsonWebTokenError: jwt audience invalid : le claim aud du jeton ne correspond pas à l’audience attendue au verify. Alignez les valeurs entre signature et vérification.
  • secretOrPrivateKey must have a value : la variable d’environnement est undefined. Confirmez que import 'dotenv/config' s’exécute avant la lecture du secret.
  • Le cookie refreshToken n’est pas envoyé : en HTTPS local manquant, l’attribut secure: true bloque le cookie. En développement, testez derrière un proxy HTTPS ou un certificat local.
  • CORS bloque la requête avec credentials : ajoutez credentials: true côté serveur et withCredentials côté client, et spécifiez une origine exacte (pas le joker).
  • req.user est undefined : le middleware authenticate n’est pas appliqué à la route, ou il a échoué silencieusement. Vérifiez l’ordre des middlewares et la présence de l’en-tête Authorization.

Astuces avancées : jose, JWKS et clés asymétriques

Pour les architectures distribuées modernes, la bibliothèque jose est une alternative puissante à jsonwebtoken. Elle implémente l’ensemble des standards JOSE (JWT, JWS, JWE, JWK) en s’appuyant sur les API Web Crypto natives, avec un support TypeScript et ESM de premier ordre. Elle brille particulièrement avec les clés asymétriques et la récupération de clés via un endpoint JWKS.

// Verification avec jose et un JWKS distant (RS256)
import * as jose from 'jose';

const JWKS = jose.createRemoteJWKSet(
  new URL('https://votre-fournisseur/.well-known/jwks.json')
);

const { payload } = await jose.jwtVerify(token, JWKS, {
  issuer: 'https://votre-fournisseur/',
  audience: 'votre-api',
});
console.log(payload.sub);

Cette approche est la norme pour OpenID Connect et les fournisseurs d’identité comme Auth0, Keycloak ou les services cloud. Votre API n’a jamais besoin du secret : elle télécharge les clés publiques depuis l’endpoint JWKS, les met en cache, et vérifie les jetons en local. La rotation des clés côté fournisseur est transparente. Pour comprendre les fondations cryptographiques sous-jacentes, notre article sur SHA-256 complète utilement ce tutoriel.

Dernière astuce de production : journalisez les échecs de vérification sans jamais journaliser le jeton complet. Un jeton dans les logs équivaut à un mot de passe en clair. Enregistrez plutôt le sub, la raison de l’échec et l’horodatage. Couplez cela à une surveillance des pics d’erreurs 401/403 pour détecter une attaque en cours. Le code source officiel de node-jsonwebtoken reste la meilleure référence pour les options disponibles.

Foire aux questions sur l’authentification JWT

Un JWT est-il chiffré ?

Non. Un JWT standard (JWS) est signé, pas chiffré. Son payload est encodé en Base64URL, donc lisible par quiconque l’intercepte. La signature garantit l’intégrité, pas la confidentialité. Pour chiffrer le contenu, il faut utiliser JWE.

Quelle durée de vie choisir pour un access token ?

Entre 5 et 15 minutes pour la plupart des applications. Plus c’est court, moins un jeton volé est dangereux. Le refresh token, plus long (7 à 30 jours), évite à l’utilisateur de se reconnecter sans cesse. La valeur exacte dépend de votre modèle de menace.

JWT ou sessions classiques, lequel choisir ?

Les sessions conviennent aux applications monolithiques où la révocation immédiate compte. JWT excelle dans les architectures distribuées et les API consommées par plusieurs clients. L’approche hybride de ce tutoriel, access token JWT plus refresh token suivi en base, combine les avantages des deux.

Comment révoquer un JWT avant son expiration ?

Par nature, un access token sans état ne se révoque pas avant expiration. Les solutions : garder l’access token très court, maintenir une denylist en Redis, et suivre les refresh tokens côté serveur pour pouvoir couper le renouvellement. La rotation détecte aussi les jetons volés réutilisés.

jsonwebtoken ou jose, quelle bibliothèque utiliser ?

jsonwebtoken (9.0.3) reste idéal pour HS256 et les projets simples : API stable et documentation abondante. jose s’impose pour les standards complets (JWE, JWK, JWKS), les clés asymétriques et les contextes OpenID Connect. Les deux sont maintenus en 2026.

Dois-je hacher le mot de passe si j’utilise JWT ?

Absolument. JWT gère la session après connexion, il ne remplace pas le stockage sécurisé des mots de passe. Hachez toujours avec bcrypt ou, mieux, argon2id avant de comparer. JWT et hachage de mot de passe résolvent deux problèmes différents et complémentaires.

HS256 est-il moins sûr que RS256 ?

Non, à force de clé égale. La différence est architecturale : HS256 partage un secret, RS256 sépare signature et vérification. Pour un service unique, HS256 avec un secret de 256 bits est parfaitement sûr. RS256 devient nécessaire quand plusieurs services doivent vérifier sans partager le secret.

Conclusion : une base JWT prête pour la production

Vous disposez désormais d’un système d’authentification JWT complet en Node.js : hachage bcrypt des mots de passe, access tokens courts, refresh tokens avec rotation, middleware verrouillé sur HS256, et stockage client résistant au XSS. Les douze étapes couvrent l’essentiel des recommandations de la RFC 8725 et de l’OWASP. Le reste relève de l’industrialisation : base de données réelle, Redis pour les jetons, rate limiting et surveillance.

Retenez les trois règles qui évitent 90 % des failles : verrouillez l’algorithme au verify, imposez une expiration courte, ne stockez jamais l’access token dans localStorage. Avec ces principes et le code de ce tutoriel, votre API repose sur des fondations solides pour 2026.