Un développeur signe ses jetons avec une clé HS256 partagée, pousse le code en production, puis découvre un an plus tard que cette même clé traîne dans trois dépôts Git différents. C’est l’origine la plus fréquente des incidents JWT recensés en 2026. La faille CVE-2026-85394, qui touche la bibliothèque Python python-jose jusqu’à sa version 3.5.0, a remis ce problème sur le devant de la scène : quand un serveur accepte indifféremment RS256 et HS256 pour vérifier un jeton, un attaquant peut récupérer la clé publique RSA et l’utiliser comme secret HMAC pour forger des jetons valides. Cette faille affiche un score CVSS 3.1 de 9,1 selon la fiche publiée sur le registre NVD, et elle corrige de façon incomplète une vulnérabilité similaire identifiée dès 2024 sous la référence CVE-2024-33663.

Ce tutoriel construit, en Node.js, un système de signature JWT qui élimine ce risque à la racine. Nous utilisons RS256 (RSA) et ES256 (courbe elliptique P-256), deux algorithmes asymétriques où la clé de vérification est publique et ne peut jamais servir à signer. Le projet final couvre la génération des clés, la signature, la vérification avec liste blanche stricte, un point de terminaison JWKS pour la rotation des clés, un middleware Express et un mécanisme de révocation par identifiant de jeton. Comptez environ 70 minutes pour suivre les 13 étapes.

Pourquoi RS256 et ES256 valent mieux que HS256 pour vos API

HS256 repose sur une seule clé secrète partagée entre l’émetteur et tous les services qui vérifient le jeton. Dès qu’un deuxième service doit effectuer cette vérification, la clé circule sur le réseau, dans des fichiers de configuration, parfois copiée à la main entre équipes. Chaque copie supplémentaire élargit la surface d’exposition. Si cette clé fuit, n’importe qui peut forger un jeton valide pour n’importe quel utilisateur.

RS256 et ES256 séparent les deux rôles. Le service d’authentification garde la clé privée et signe seul. Les autres services reçoivent uniquement la clé publique, suffisante pour vérifier mais inutile pour forger un jeton. Un vérificateur compromis ne permet donc jamais à un attaquant de signer de nouveaux jetons, il peut seulement lire ceux déjà émis.

Le document RFC 8725, qui compile les bonnes pratiques JWT publiées par l’IETF, insiste sur un point précis : ne jamais dériver l’algorithme accepté depuis l’en-tête du jeton lui-même. C’est exactement la faille exploitée par la confusion RS256/HS256. Un serveur qui appelle une fonction de vérification sans préciser explicitement la liste des algorithmes acceptés laisse la bibliothèque faire confiance au champ alg fourni par l’attaquant. Si ce dernier envoie un jeton annoncé en HS256 et signé avec la clé publique RSA utilisée comme secret, certaines implémentations le valident sans broncher.

ES256 ajoute un avantage pratique au-delà de la sécurité : la compacité. Une clé publique P-256 pèse environ 32 octets contre 256 à 384 octets pour une clé RSA offrant un niveau de sécurité comparable, selon les recommandations générales du NIST sur les tailles de clé (SP 800-57). Pour des jetons transmis dans un en-tête HTTP à chaque requête, cette différence réduit la bande passante consommée, un détail qui compte sur mobile ou pour des appareils connectés à faible débit.

Prérequis : versions et outils pour ce tutoriel

Voici l’environnement exact utilisé pour ce tutoriel. Installez chaque outil avant de commencer, les commandes de vérification sont données à l’étape 1.

Outil ou bibliothèqueVersion utiliséeRôle dans le projet
Node.jsv24.21.0 LTS, nom de code “Krypton”Environnement d’exécution JavaScript
npm11.19.0 (fournie avec Node 24.21.0)Gestion des dépendances
jsonwebtoken9.0.3Signature et vérification RS256
jose6.2.12Signature ES256, export JWKS, claims
express5.3.0API HTTP et middleware d’authentification
dotenv18.0.7Chargement des variables d’environnement
OpenSSL3.0 ou supérieurGénération des paires de clés RSA et EC

Node.js 24 est entré en phase de support LTS actif le 28 octobre 2025 et le reste jusqu’au 20 octobre 2026, avant de passer en maintenance jusqu’au 30 avril 2028. La version 24.21.0 utilisée ici date du 7 septembre 2026. Si votre machine tourne encore sous Node 22 (“Jod”, en maintenance LTS jusqu’au 30 avril 2027), le code de ce tutoriel fonctionne également, le module crypto natif n’a pas changé d’API entre les deux versions pour les opérations utilisées ici.

Comprendre l’anatomie d’un jeton JWT

Un JWT est une chaîne composée de trois segments séparés par des points : l’en-tête, la charge utile et la signature, chacun encodé en base64url. L’en-tête et la charge utile ne sont jamais chiffrés, seulement encodés. N’importe qui peut les lire en les décodant, y compris un utilisateur final qui inspecte son propre jeton dans les outils de développement du navigateur. Seule la signature garantit que le contenu n’a pas été modifié depuis son émission par le détenteur de la clé privée.

Voici un en-tête et une charge utile réels, tels que ce tutoriel va les produire, décodés depuis leur forme base64url :

echo "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJzYS0yMDI2LTEwIn0" | base64 -d
# {"alg":"RS256","typ":"JWT","kid":"rsa-2026-10"}

echo "eyJzdWIiOiJ1XzQyIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLm1vbmFwcC5mciIsImF1ZCI6Imh0dHBzOi8vYXBpLm1vbmFwcC5mciIsInNjb3BlIjoiYXBpOnJlYWQiLCJpYXQiOjE3NjAxNjk2MDAsImV4cCI6MTc2MDE3MDUwMCwianRpIjoiOGYxNGU0NWYtY2VlYS0xNjdhLWEzYjItOWQ0YzNiNGYwYTFlIn0" | base64 -d
# {"sub":"u_42","iss":"https://auth.monapp.fr","aud":"https://api.monapp.fr","scope":"api:read","iat":1760169600,"exp":1760170500,"jti":"8f14e45f-ceea-167a-a3b2-9d4c3b4f0a1e"}

Le champ kid (key ID) identifie quelle clé publique utiliser parmi plusieurs, utile pour la rotation. Le champ jti (JWT ID) donne un identifiant unique au jeton, indispensable pour construire une liste de révocation. Les champs iss (émetteur), aud (destinataire) et exp (expiration) sont définis par le RFC 7519, la spécification officielle du JWT.

RS256 vs ES256 vs HS256 : le tableau comparatif

CritèreHS256RS256ES256
Type de cléSecrète partagéePaire RSA (privée et publique)Paire EC P-256 (privée et publique)
Taille de clé publiqueInexistante256 à 384 octets (RSA-2048/3072)Environ 32 à 91 octets
Taille de signature32 octets256 à 384 octets64 octets
Résiste à la confusion d’algorithmeNon, sauf liste blanche stricte côté serveurOui, avec liste blanche stricteOui, avec liste blanche stricte
Cas d’usage recommandéCommunication interne mono-serviceAPI multi-services, interopérabilité largeMulti-services, mobile, objets connectés
Risque principalFuite de la clé secrète = compromission totaleLa clé privée doit rester uniquement côté émetteurIdentique à RS256, courbe P-256 ou P-384 recommandée

Ce tableau explique pourquoi ce tutoriel construit les deux schémas asymétriques en parallèle plutôt qu’un seul. En pratique, beaucoup d’équipes utilisent RS256 pour la compatibilité avec des systèmes existants (SSO, fournisseurs d’identité tiers) et basculent progressivement vers ES256 sur les nouveaux services pour la compacité des clés. Les deux cohabitent sans problème dans un même endpoint JWKS, à condition de bien identifier chaque clé par son champ kid.

Performance et taille des jetons : ce qui change concrètement

La différence de coût entre les trois algorithmes vient directement de leur construction mathématique. RSA repose sur une exponentiation modulaire avec un très grand module, une opération coûteuse pour la signature mais relativement légère pour la vérification, car l’exposant public est petit (généralement 65537). ECDSA, utilisé par ES256, demande un calcul sur courbe elliptique dont le coût de signature et de vérification reste du même ordre de grandeur, sans la même asymétrie que RSA. HMAC, derrière HS256, ne fait qu’un hachage avec une clé, l’opération la plus légère des trois, ce qui explique pourquoi cet algorithme reste répandu dans des contextes où la performance brute prime sur la séparation des rôles entre émetteur et vérificateur.

Sur un serveur qui vérifie des milliers de jetons par seconde, RS256 reste généralement le choix le moins coûteux à la vérification grâce à son petit exposant public, un avantage qui compte pour une API de type passerelle qui ne fait que valider des jetons émis ailleurs. ES256, à l’inverse, demande un effort comparable entre signature et vérification, un profil plus adapté à un service d’authentification qui émet autant de jetons qu’il en vérifie. Dans les deux cas, la différence reste de l’ordre du milliseconde par opération sur du matériel serveur courant, négligeable devant la latence réseau d’une requête HTTP classique.

Le second facteur, souvent sous-estimé, concerne la taille du jeton lui-même. Un jeton RS256 avec une clé de 3072 bits produit une signature de 384 octets, contre 64 octets pour ES256. Multiplié par chaque requête HTTP qui transporte ce jeton dans son en-tête, l’écart cumulé devient significatif sur une application mobile avec une connexion contrainte, ou sur une architecture où des millions de requêtes transitent chaque jour entre microservices internes.

Étape 1 : Vérifier Node.js et initialiser le projet

Confirmez d’abord que votre installation correspond aux prérequis, puis créez le dossier du projet.

node --version
# v24.21.0 attendu (ou v22.x en LTS de maintenance)

mkdir jwt-rs256-es256-demo && cd jwt-rs256-es256-demo
npm init -y
mkdir keys src

Ouvrez le fichier package.json généré et ajoutez "type": "module" à la racine de l’objet. Tout le code de ce tutoriel utilise la syntaxe ES Modules (import/export), plus proche de ce qu’utilisent jose et les projets Node.js récents.

Étape 2 : Générer la paire de clés RSA-3072 avec OpenSSL

RSA-2048 reste le minimum accepté par la plupart des audits de sécurité, mais RSA-3072 offre une marge supplémentaire pour un coût de calcul encore raisonnable sur un serveur applicatif classique. Générez la clé privée au format PKCS#8, plus largement supporté que le format PKCS#1 historique.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out keys/private-rsa.pem
openssl rsa -pubout -in keys/private-rsa.pem -out keys/public-rsa.pem

# Vérification rapide de la clé
openssl rsa -in keys/private-rsa.pem -check -noout
# RSA key ok

Ne placez jamais ce dossier keys dans votre dépôt Git. Ajoutez-le immédiatement à votre fichier .gitignore avant d’aller plus loin, l’étape suivante génère une seconde paire de clés dans le même dossier.

Étape 3 : Générer la paire de clés EC P-256 avec OpenSSL

La courbe prime256v1, aussi appelée P-256 ou secp256r1, correspond à l’algorithme ES256. Elle est supportée nativement par toutes les bibliothèques JWT modernes et par l’API Web Crypto des navigateurs.

openssl ecparam -name prime256v1 -genkey -noout -out keys/private-ec-sec1.pem
openssl pkcs8 -topk8 -nocrypt -in keys/private-ec-sec1.pem -out keys/private-ec.pem
openssl ec -in keys/private-ec-sec1.pem -pubout -out keys/public-ec.pem
rm keys/private-ec-sec1.pem

La conversion vers PKCS#8 (deuxième commande) est nécessaire car jose attend ce format pour importer une clé privée EC. Le fichier intermédiaire au format SEC1 est supprimé juste après, il ne sert qu’à l’étape de conversion.

Étape 4 : Installer jsonwebtoken et jose

Ce tutoriel utilise deux bibliothèques volontairement : jsonwebtoken pour RS256, largement adoptée et simple d’API, et jose pour ES256, plus moderne et conforme strictement aux RFC JOSE (JWT, JWS, JWA). Voir les deux approches côte à côte aide à choisir en connaissance de cause pour un projet réel.

npm install [email protected] [email protected] [email protected] [email protected]

Exécutez npm audit après l’installation. Le champ engines de jose impose Node.js 20.19 ou supérieur, largement couvert par la version 24 utilisée ici.

Étape 5 : Charger les clés en toute sécurité

Centralisez le chargement des clés dans un seul module. Cela évite de dupliquer les chemins de fichiers dans chaque script et facilite le passage à un gestionnaire de secrets (Vault, AWS Secrets Manager) en production, en ne modifiant qu’un seul fichier.

// src/keys.js
import { readFileSync } from 'node:fs';

export const rsaPrivateKeyPem = readFileSync('./keys/private-rsa.pem', 'utf8');
export const rsaPublicKeyPem = readFileSync('./keys/public-rsa.pem', 'utf8');
export const ecPrivateKeyPem = readFileSync('./keys/private-ec.pem', 'utf8');
export const ecPublicKeyPem = readFileSync('./keys/public-ec.pem', 'utf8');

En production, remplacez les deux lectures de clés privées par un appel à votre gestionnaire de secrets, et gardez les variables d’environnement uniquement pour les paramètres non sensibles comme l’émetteur (iss) et le destinataire (aud) attendus. Créez un fichier .env avec ces deux valeurs, chargé via dotenv au démarrage de l’application.

Étape 6 : Signer un jeton en RS256 avec jsonwebtoken

Signez toujours en précisant explicitement l’algorithme, l’émetteur, le destinataire et une durée d’expiration courte. Un access token de 15 minutes limite fortement la fenêtre d’exploitation si le jeton venait à fuiter.

// src/sign-rs256.js
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
import { rsaPrivateKeyPem } from './keys.js';

export function signAccessTokenRs256(userId, scope) {
  return jwt.sign(
    { scope },
    rsaPrivateKeyPem,
    {
      algorithm: 'RS256',
      subject: userId,
      expiresIn: '15m',
      issuer: 'https://auth.monapp.fr',
      audience: 'https://api.monapp.fr',
      jwtid: randomUUID(),
      keyid: 'rsa-2026-10',
    }
  );
}

L’option keyid écrit automatiquement le champ kid dans l’en-tête du jeton, celui que nous avons décodé plus haut. C’est ce champ que le vérificateur utilisera à l’étape 10 pour choisir la bonne clé publique dans le JWKS.

Étape 7 : Vérifier le jeton avec une liste blanche d’algorithmes

C’est l’étape qui bloque la classe de faille illustrée par CVE-2026-85394. Le tableau algorithms passé en option n’est pas facultatif : sans lui, certaines versions de bibliothèques JWT acceptent l’algorithme annoncé dans l’en-tête du jeton, y compris none ou HS256 avec la clé publique utilisée comme secret.

// src/verify-rs256.js
import jwt from 'jsonwebtoken';
import { rsaPublicKeyPem } from './keys.js';

export function verifyAccessTokenRs256(token) {
  return jwt.verify(token, rsaPublicKeyPem, {
    algorithms: ['RS256'], // jamais HS256, jamais 'none'
    issuer: 'https://auth.monapp.fr',
    audience: 'https://api.monapp.fr',
    clockTolerance: 5, // secondes, pour compenser un léger décalage d'horloge
  });
}

Le paramètre clockTolerance absorbe un petit décalage entre l’horloge du serveur qui signe et celle du serveur qui vérifie, fréquent sur des infrastructures distribuées. Cinq secondes suffisent largement dans la grande majorité des cas, inutile de monter à plusieurs minutes.

Étape 8 : Signer et vérifier un jeton en ES256 avec jose

La bibliothèque jose expose une API chaînée pour construire un jeton, chaque méthode ajoutant un claim. Elle impose aussi l’import explicite des clés au format PKCS#8 et SPKI, ce qui force à manipuler des objets clés typés plutôt que de simples chaînes PEM.

// src/sign-verify-es256.js
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
import { randomUUID } from 'node:crypto';
import { ecPrivateKeyPem, ecPublicKeyPem } from './keys.js';

const ALG = 'ES256';
const privateKey = await importPKCS8(ecPrivateKeyPem, ALG);
const publicKey = await importSPKI(ecPublicKeyPem, ALG);

export async function signAccessTokenEs256(userId, scope) {
  return new SignJWT({ scope })
    .setProtectedHeader({ alg: ALG, kid: 'ec-2026-10' })
    .setSubject(userId)
    .setIssuer('https://auth.monapp.fr')
    .setAudience('https://api.monapp.fr')
    .setExpirationTime('15m')
    .setIssuedAt()
    .setJti(randomUUID())
    .sign(privateKey);
}

export async function verifyAccessTokenEs256(token) {
  const { payload } = await jwtVerify(token, publicKey, {
    algorithms: [ALG],
    issuer: 'https://auth.monapp.fr',
    audience: 'https://api.monapp.fr',
  });
  return payload;
}

Remarquez que jwtVerify exige lui aussi le tableau algorithms. La bibliothèque jose a été conçue après la publication du RFC 8725, cette contrainte est donc intégrée par défaut dans son design plutôt qu’ajoutée en option a posteriori.

Étape 9 : Ajouter les claims standards et un identifiant de clé

Le RFC 7519 définit sept claims enregistrés : iss, sub, aud, exp, nbf, iat et jti. Aucun n’est obligatoire selon la spécification, mais en pratique un système de production devrait tous les renseigner.

  • iss (issuer) : identifie quel service a émis le jeton, vérifié systématiquement côté destinataire.
  • aud (audience) : identifie pour quel service le jeton a été émis, empêche un jeton valide pour un service A d’être accepté par un service B.
  • exp (expiration) : horodatage Unix après lequel le jeton est rejeté.
  • nbf (not before) : horodatage avant lequel le jeton n’est pas encore valide, utile pour des jetons pré-générés.
  • jti (JWT ID) : identifiant unique, indispensable pour la révocation individuelle construite à l’étape 12.

Le champ kid n’appartient pas à la charge utile mais à l’en-tête du jeton. Il n’est pas standardisé par le RFC 7519 lui-même mais par la spécification JWS (RFC 7518 pour l’algorithme associé). Adoptez une convention de nommage stable dès le départ, par exemple <algorithme>-<année>-<mois> comme utilisé dans ce tutoriel, pour retrouver facilement quelle clé a signé quel jeton lors d’un audit.

Étape 10 : Publier un endpoint JWKS pour la rotation des clés

Un JWKS (JSON Web Key Set) expose publiquement les clés de vérification à l’adresse conventionnelle /.well-known/jwks.json. Les services clients récupèrent ce document périodiquement au lieu de stocker une clé publique en dur, ce qui permet de faire tourner les clés sans redéployer chaque consommateur.

// src/jwks.js
import { exportJWK, importSPKI } from 'jose';
import { rsaPublicKeyPem, ecPublicKeyPem } from './keys.js';

export async function buildJwks() {
  const rsaPublicKey = await importSPKI(rsaPublicKeyPem, 'RS256');
  const ecPublicKey = await importSPKI(ecPublicKeyPem, 'ES256');

  const rsaJwk = await exportJWK(rsaPublicKey);
  const ecJwk = await exportJWK(ecPublicKey);

  return {
    keys: [
      { ...rsaJwk, kid: 'rsa-2026-10', use: 'sig', alg: 'RS256' },
      { ...ecJwk, kid: 'ec-2026-10', use: 'sig', alg: 'ES256' },
    ],
  };
}

Pour tourner une clé sans interruption de service, publiez d’abord la nouvelle clé publique dans le JWKS aux côtés de l’ancienne, commencez à signer les nouveaux jetons avec la nouvelle clé et son nouveau kid, puis retirez l’ancienne clé du JWKS seulement une fois que tous les jetons signés avec elle ont expiré naturellement.

Étape 11 : Construire le middleware Express de vérification

Le middleware extrait le jeton de l’en-tête Authorization, le vérifie, puis attache la charge utile décodée à l’objet req pour les routes suivantes. Express 5.3.0 gère nativement les erreurs levées dans un middleware asynchrone, ce qui simplifie la gestion des rejets par rapport à Express 4.

// src/server.js
import express from 'express';
import { verifyAccessTokenRs256 } from './verify-rs256.js';
import { buildJwks } from './jwks.js';
import { isRevoked } from './revocation.js';

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

app.get('/.well-known/jwks.json', async (req, res) => {
  res.json(await buildJwks());
});

function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'token_missing' });
  }
  try {
    const payload = verifyAccessTokenRs256(header.slice(7));
    if (isRevoked(payload.jti)) {
      return res.status(401).json({ error: 'token_revoked' });
    }
    req.user = payload;
    next();
  } catch (err) {
    res.status(401).json({ error: 'token_invalid', detail: err.message });
  }
}

app.get('/api/profil', requireAuth, (req, res) => {
  res.json({ sub: req.user.sub, scope: req.user.scope });
});

app.listen(3000, () => console.log('API démarrée sur le port 3000'));

Le middleware renvoie trois codes d’erreur distincts (token_missing, token_revoked, token_invalid), ce qui facilite grandement le diagnostic côté client sans révéler d’information utile à un attaquant sur la raison exacte du rejet.

Étape 12 : Gérer le refresh token et la révocation par jti

Un JWT signé ne peut pas être invalidé directement, la signature reste mathématiquement valide jusqu’à expiration. La seule façon de révoquer un jeton avant son terme est de maintenir une liste d’identifiants jti rejetés, consultée à chaque vérification. L’exemple ci-dessous utilise une structure en mémoire pour la démonstration, remplacez-la par Redis avec une expiration automatique (EXPIRE) en production pour que la liste survive à un redémarrage et reste partagée entre plusieurs instances de l’API.

// src/revocation.js
const revokedUntil = new Map();

export function revokeToken(jti, expUnixSeconds) {
  revokedUntil.set(jti, expUnixSeconds * 1000);
}

export function isRevoked(jti) {
  const expiryMs = revokedUntil.get(jti);
  if (!expiryMs) return false;
  if (Date.now() > expiryMs) {
    revokedUntil.delete(jti); // nettoyage, le jeton a de toute façon expiré
    return false;
  }
  return true;
}

Stocker la date d’expiration du jeton avec son jti permet de purger automatiquement l’entrée une fois le jeton expiré naturellement, au lieu de garder une liste de révocation qui grossit indéfiniment. Pour les refresh tokens, qui vivent souvent plusieurs jours, appliquez le même principe mais avec une durée de vie plus longue et, idéalement, une rotation à chaque utilisation : chaque refresh émet un nouveau refresh token et révoque l’ancien.

Étape 13 : Tester le projet de bout en bout avec curl

Lancez le serveur, puis vérifiez les trois comportements clés : émission d’un jeton, accès autorisé avec ce jeton, et rejet après révocation.

node src/server.js
# API démarrée sur le port 3000

curl -s http://localhost:3000/.well-known/jwks.json | head -c 200
# {"keys":[{"kty":"RSA","n":"...","e":"AQAB","kid":"rsa-2026-10","use":"sig","alg":"RS256"},...

curl -s http://localhost:3000/api/profil
# {"error":"token_missing"}

TOKEN=$(node -e "import('./src/sign-rs256.js').then(m => console.log(m.signAccessTokenRs256('u_42','api:read')))")
curl -s http://localhost:3000/api/profil -H "Authorization: Bearer $TOKEN"
# {"sub":"u_42","scope":"api:read"}

Si la dernière commande renvoie bien l’objet avec sub et scope, la chaîne complète fonctionne : génération de clés, signature RS256, vérification stricte et middleware Express. Testez ensuite la révocation en appelant revokeToken avec le jti extrait du jeton (décodez-le comme à l’étape sur l’anatomie du JWT), puis relancez la même requête curl, elle doit désormais renvoyer token_revoked.

Le projet complet : arborescence des fichiers

À la fin de ce tutoriel, votre dossier de projet ressemble à ceci :

jwt-rs256-es256-demo/
├── keys/
│   ├── private-rsa.pem      (ne jamais committer)
│   ├── public-rsa.pem
│   ├── private-ec.pem       (ne jamais committer)
│   └── public-ec.pem
├── src/
│   ├── keys.js
│   ├── sign-rs256.js
│   ├── verify-rs256.js
│   ├── sign-verify-es256.js
│   ├── jwks.js
│   ├── revocation.js
│   └── server.js
├── .env
├── .gitignore
└── package.json

Ce squelette couvre les deux schémas de signature, un point de terminaison de rotation de clés et un mécanisme de révocation minimal. Pour un service réel, ajoutez une route /auth/login qui authentifie l’utilisateur par mot de passe ou OAuth avant d’appeler signAccessTokenRs256, et une route /auth/refresh qui vérifie un refresh token avant d’en émettre un nouveau.

Pièges courants à éviter avec les JWT

  • Ne pas restreindre les algorithmes acceptés. Omettre le tableau algorithms à la vérification ouvre la porte à la confusion RS256/HS256 décrite en introduction, exactement le mécanisme derrière CVE-2026-85394.
  • Committer une clé privée dans Git. Même après suppression du fichier, la clé reste dans l’historique git. Si cela arrive, régénérez la paire de clés entièrement plutôt que de simplement supprimer le fichier.
  • Oublier une durée d’expiration courte sur l’access token. Un jeton sans expiresIn, ou avec une durée de plusieurs jours, reste exploitable très longtemps en cas de fuite.
  • Utiliser le même jeton comme access token et refresh token. Les deux ont des durées de vie et des niveaux de risque différents, ils doivent être deux jetons distincts, idéalement signés avec des clés différentes.
  • Ne pas vérifier issuer et audience. Un jeton signé par le bon service mais destiné à un autre système peut être rejoué ailleurs si ces deux champs ne sont pas contrôlés à la vérification.
  • Faire tourner une clé sans champ kid. Sans identifiant de clé dans l’en-tête, impossible de savoir quelle clé publique utiliser pendant une période de transition entre deux clés.
  • Fixer une tolérance d’horloge excessive. Une valeur de clockTolerance de plusieurs minutes annule une partie de l’intérêt d’une expiration courte.

Dépannage : les problèmes les plus fréquents

SymptômeCause probableSolution
JsonWebTokenError: invalid signatureLa clé publique utilisée pour vérifier ne correspond pas à la clé privée ayant signéVérifiez que le bon fichier public-rsa.pem ou public-ec.pem est chargé, et que les deux clés viennent de la même génération OpenSSL
JsonWebTokenError: jwt malformedLe jeton transmis n’a pas trois segments séparés par des points, souvent une troncature côté clientVérifiez l’en-tête Authorization complet reçu côté serveur avec un log temporaire, en particulier après un proxy ou un load balancer
TokenExpiredError: jwt expiredLe jeton a dépassé sa durée de vie définie par expiresInComportement attendu, le client doit utiliser son refresh token pour obtenir un nouvel access token
NotBeforeError: jwt not activeDécalage d’horloge entre le serveur émetteur et le serveur vérificateurSynchronisez les horloges via NTP et ajoutez une clockTolerance de 5 secondes
error:0909006C:PEM routinesLe fichier de clé a été corrompu ou tronqué, les lignes BEGIN/END PEM manquentRégénérez la paire de clés avec les commandes OpenSSL de l’étape 2 ou 3, vérifiez l’encodage du fichier (UTF-8 sans BOM)
“invalid algorithm” alors que le jeton semble correctLe tableau algorithms côté vérification ne contient pas l’algorithme utilisé pour signerAlignez exactement la valeur passée à algorithm lors de la signature et à algorithms lors de la vérification
JWSSignatureVerificationFailed avec joseLa clé importée via importSPKI ne correspond pas à l’algorithme déclaré en second argumentVérifiez que le second argument de importSPKI ou importPKCS8 correspond exactement à l’algorithme (ES256, pas ES384) de la clé générée
CORS bloque l’en-tête AuthorizationLe serveur n’autorise pas explicitement cet en-tête dans sa configuration CORSAjoutez Authorization à la liste allowedHeaders de votre middleware CORS côté serveur
Le point de terminaison JWKS renvoie 404 derrière un reverse proxyLe chemin /.well-known/ est parfois intercepté ou réécrit par la configuration du proxyAjoutez une règle explicite dans Nginx ou votre proxy pour laisser passer /.well-known/jwks.json sans réécriture
Un jeton révoqué est quand même acceptéLe middleware vérifie la signature mais n’appelle jamais isRevokedInsérez l’appel à isRevoked juste après la vérification de signature, avant d’attacher req.user

Conseils avancés pour la production

Pour des consommateurs externes à votre infrastructure, utilisez createRemoteJWKSet de jose plutôt que de coder en dur la clé publique. Cette fonction récupère automatiquement le JWKS distant, met le résultat en cache et le rafraîchit en arrière-plan, ce qui rend la rotation de clé totalement transparente pour le consommateur du jeton, sans redéploiement de son côté.

Séparez systématiquement la clé de signature des access tokens de celle des refresh tokens, même en RS256 pour les deux. Un refresh token qui fuit expose un risque différent d’un access token qui fuit, puisqu’il permet de regénérer indéfiniment de nouveaux access tokens. Utiliser des clés distinctes limite l’impact d’une compromission à une seule catégorie de jeton.

Journalisez chaque échec de vérification avec le motif précis (signature invalide, algorithme rejeté, émetteur incorrect) dans un système de logs centralisé. Un pic soudain d’erreurs “invalid algorithm” sur une courte période constitue un signal fort de tentative de confusion d’algorithme en cours, le même type d’attaque que celle exploitée par CVE-2026-85394 sur python-jose. Un tel pic mérite une alerte immédiate plutôt qu’une simple ligne de log.

Gardez aussi un œil sur les alternatives au format JWT classique pour les nouveaux projets. PASETO, par exemple, élimine entièrement le champ alg modifiable par l’attaquant en figeant la version du protocole et l’algorithme au niveau du format du jeton lui-même, plutôt que dans un en-tête interprété au moment de la vérification. Cela ne remplace pas une bonne implémentation JWT comme celle de ce tutoriel, mais vaut le détour pour un système construit entièrement de zéro.

FAQ : JWT RS256 et ES256 en Node.js

Faut-il choisir RS256 ou ES256 pour un nouveau projet ?
ES256 convient à la majorité des nouveaux projets grâce à des clés et des signatures plus compactes. Choisissez RS256 si vous devez interopérer avec un système tiers (fournisseur d’identité, SSO d’entreprise) qui n’accepte que cet algorithme, ce qui reste fréquent avec des systèmes plus anciens.

Peut-on utiliser HS256 en toute sécurité ?
Oui, dans un contexte précis : un seul service émet et vérifie les jetons, sans partage de clé avec un tiers. Dès qu’un deuxième service doit vérifier les jetons de façon indépendante, basculez vers RS256 ou ES256 pour éviter de distribuer la clé secrète.

Combien de temps doit durer un access token JWT ?
Entre 5 et 15 minutes pour un access token classique, complété par un refresh token de plus longue durée (plusieurs heures à quelques jours) stocké de façon plus protégée. Cette séparation limite la fenêtre d’exploitation d’un access token volé tout en évitant de forcer l’utilisateur à se reconnecter trop souvent.

Comment révoquer un JWT avant son expiration naturelle ?
Un JWT signé reste valide mathématiquement jusqu’à expiration, il n’existe pas de mécanisme de révocation intégré au format. La seule solution fiable consiste à maintenir une liste de jti révoqués, consultée à chaque vérification, comme construit à l’étape 12 de ce tutoriel.

jsonwebtoken ou jose, quelle bibliothèque choisir en 2026 ?
jsonwebtoken (version 9.0.3) reste pertinent pour sa simplicité d’API et sa large adoption. jose (version 6.2.12) couvre un périmètre plus large, notamment le chiffrement JWE et l’export JWKS natif, utile pour un projet qui gère plusieurs formats JOSE au-delà du simple JWT signé.

Qu’est-ce que le champ kid et pourquoi l’utiliser systématiquement ?
Le champ kid identifie quelle clé publique, parmi plusieurs publiées dans le JWKS, correspond à la signature du jeton. Sans ce champ, une rotation de clé oblige à tester toutes les clés publiques disponibles à chaque vérification, ce qui ne tient pas à l’échelle au-delà de deux ou trois clés.

Un JWT est-il chiffré ?
Non, par défaut un JWT signé (JWS) encode simplement son contenu en base64url, sans chiffrement. N’importe qui interceptant le jeton peut lire l’en-tête et la charge utile. Pour un contenu sensible nécessitant la confidentialité, utilisez un JWE (JSON Web Encryption), une extension distincte de la même famille de spécifications.

Comment migrer une API existante de HS256 vers RS256 sans interruption ?
Publiez d’abord la clé publique RSA dans un endpoint JWKS tout en continuant à signer en HS256. Modifiez ensuite le vérificateur pour qu’il accepte temporairement les deux algorithmes (HS256 et RS256) avec leurs clés respectives distinctes. Basculez la signature vers RS256 uniquement une fois ce vérificateur double déployé partout, puis retirez le support HS256 après l’expiration de tous les anciens jetons.

Faut-il signer les jetons côté client en JavaScript navigateur ?
Non, jamais. La signature doit toujours rester côté serveur, là où la clé privée est stockée hors de portée de l’utilisateur final. Un navigateur ne doit manipuler que des jetons déjà signés, reçus après authentification, et les transmettre dans l’en-tête Authorization des requêtes suivantes. L’API Web Crypto du navigateur peut vérifier une signature côté client pour un usage spécifique, mais ne doit jamais détenir la clé privée de signature.