Lorsque l’authentification JWT devient étatique : un argument en faveur des sessions côté serveur dans Node
Les listes de blocage et les mécanismes de mise à jour rendent l’authentification JWT étatique de toute façon. Les sessions Express basées sur Postgres dans Node.js sont plus simples, et c’est là que les JWT restent particulièrement adaptés.
Sources: Forget JSON Web Tokens: Server-Side Sessions Cut Our Auth Bugs By 70%
L’authentification JWT sans état semble élégante sur un tableau blanc, mais de nombreuses équipes découvrent que le déconnexion, la sortie forcée et les réinitialisations de mot de passe ramènent discrètement l’état dans le système. Lorsqu’une liste noire de révocation, un stockage de tokens de renouvellement et du middleware personnalisé entrent en jeu, la couche d’authentification devient la partie la plus fragile du système. Cet article examine ce schéma d’échec, montre comment une configuration de session basée sur Postgres dans Express peut le remplacer, et explique les rares cas où les JWT restent l’outil préférable.
Un schéma d’échec familier
Imaginez une équipe fière de sa configuration JWT jusqu’à ce que la file d’attente du support soit pleine. Les utilisateurs se voient déconnectés au hasard, les réinitialisations de mot de passe cessent d’être efficaces, et un client soumet trois tickets en une seule journée, chacun décrivant le même paradoxe : il est connecté et non connecté en même temps. Les ingénieurs passent quatre heures sur ce seul ticket, décodant son jeton et vérifiant sa date d’expiration, et tout semble valide.
De tels bugs apparaissent généralement dans les interstices entre les différents mécanismes, chacun ajouté pour corriger une limitation du précédent.
Pourquoi le mode « stateless » reste rarement sans état
L’argument standard en faveur des JWT est séduisant. Il n’y a ni stockage de session ni recherche dans une base de données : on place l’ID de l’utilisateur dans la charge utile, on le signe, on le transmet au client, et on laisse le jeton lui-même être la source de vérité.
Le problème réside dans la révocation. Un jeton signé reste valide jusqu’à son expiration, et le serveur ne dispose d’aucun moyen intégré pour l’annuler prématurément. Pour déconnecter un utilisateur partout, réagir à un jeton volé ou invalider l’accès après un changement de mot de passe, il faut une liste noire des jetons, ce qui correspond précisément au type d’état partagé que la conception visait à éliminer.
Dans le scénario décrit ci-dessus, l’équipe se retrouve à utiliser une liste noire Redis pour les jetons révoqués, un stockage séparé pour les jetons de renouvellement et une couche de middleware personnalisée qui les relie entre eux. Un système conçu pour être sans état est devenu le composant le plus gourmand en état au sein de l’architecture.
En quoi se transforme la vérification par requête
Une version simplifiée du middleware qui s’exécute à chaque requête ressemble à ceci : il récupère le token porteur depuis l’en-tête Authorization, le recherche dans la liste noire de Redis, puis vérifie la signature et les informations contenues dans le token à l’aide de jwt.verify.
// verify access token
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "No token" });
// check if revoked
const revoked = await redis.get(`revoked:${token}`);
if (revoked) return res.status(401).json({ error: "Token revoked" });
jwt.verify(token, process.env.SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: "Invalid token" });
req.user = decoded;
next();
});
}
Au stade actuel, la fonction ne s’exécuterait même pas : elle utilise await sans être déclarée comme async. Le problème majeur est d’ordre architectural : chaque requête effectue désormais une appel réseau vers Redis, ce qui annule l’avantage principal des JWT, à savoir l’évitement d’une recherche. De plus, cela entraîne une consommation de CPU pour la vérification du token, ainsi qu’une vérification supplémentaire concernant son expiration et ses informations.
Les bugs se cachent dans les intervalles entre ces trois vérifications :
- Un token d’accès expire, et l’appel de renouvellement échoue silencieusement.
Chacun de ces problèmes nécessite une correction à un niveau différent, et une correction à un niveau peut facilement contredire des hypothèses établies ailleurs.
Remplacer trois mécanismes par un seul stockage de sessions
L’alternative consiste à utiliser des sessions côté serveur. Le client ne contient que d’identifiants de session opaques dans un cookie, tandis que les données de session sont stockées dans votre base de données. Avec Express, express-session associé au stockage connect-pg-simple conserve les sessions dans Postgres :
const session = require("express-session");
const PgSession = require("connect-pg-simple")(session);
app.use(session({
store: new PgSession({ pool: db }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
}
}));
Quelques paramètres assurent la majeure partie de la sécurité. Le paramètre httpOnly empêche JavaScript sur la page d’accéder au cookie, ce qui limite le vol de tokens via XSS. Le paramètre secure garantit que le cookie n’est envoyé qu’over HTTPS. resave: false évite d’écrire à nouveau des sessions inchangées à chaque requête, tandis que saveUninitialized: false empêche le stockage de se remplir de sessions vides pour les visiteurs anonymes. La valeur maxAge définit une durée de vie de sept jours pour les sessions. Notez que connect-pg-simple exige l’existence d’une table de sessions ; créez-la donc dans le cadre de vos migrations.
Les opérations deviennent alors presque triviales :
- Lors de la connexion, on définit
req.session.userId. - Pour se déconnecter, on appelle
req.session.destroy(). - Une déconnexion forcée supprime les lignes correspondant à l’utilisateur de la table des sessions.
La révocation se fait désormais par une simple suppression, sans expiration de liste noire pouvant provoquer un décalage, et chaque requête lit la même ligne d’authentification fiable. Deux mesures de renforcement sont à ajouter : appeler req.session.regenerate() lors de la connexion pour éviter la fixation de session, et définir sameSite sur le cookie (ainsi que la protection CSRF lorsque c’est nécessaire), car les cookies sont envoyés automatiquement par le navigateur, contrairement aux en-têtes porteurs.
Dans le scénario décrit ici, la migration a supprimé environ 340 lignes de logique d’authentification, ce qui signifie qu’il y avait autant moins d’endroits où un bug pouvait se cacher.
Évaluer le changement
Afin d’évaluer équitablement ce changement, l’équipe a suivi chaque rapport de bug lié à l’authentification pendant 90 jours avant la migration et pendant 90 jours après. Les chiffres rapportés sont les suivants :
Before (JWT):
- Auth bugs reported: 43
- Avg debug time per bug: 2.4 hours
- Force-logout capability: not possible
After (Sessions):
- Auth bugs reported: 13
- Avg debug time per bug: 0.6 hours
- Force-logout time: < 1 second (delete row)
Reduction: ~70%
Le nombre de rapports de bugs est passé de 43 à 13, soit une réduction d’environ 70 %, et le temps moyen de débogage par bug est tombé de 2,4 heures à 0,6 heure. Le déconnexion forcée est passée d’être impossible à une suppression de ligne en moins d’une seconde. Il s’agit des chiffres d’une seule équipe, et non d’un référentiel.
L’objection habituelle concerne la recherche dans la base de données à chaque requête. Pour la plupart des applications, une lecture de la clé primaire d’une table de sessions est peu coûteuse par rapport aux requêtes déjà exécutées par une requête typique, donc le véritable goulot d’étranglement se trouve presque toujours ailleurs.
Comparaison des deux chemins de requête
Lorsqu’on les compare côte à côte, la différence est évidente. Le chemin JWT fait en sorte que le serveur décode le jeton, consulte Redis pour obtenir la liste noire, et vérifie l’expiration ainsi que les informations contenues dans le jeton avant que le gestionnaire de route ne s’exécute :
Client --> [Token in header] --> Server
|
Decode token (CPU)
|
Check Redis blocklist (network)
|
Validate expiry + claims
|
Route handler
Le chemin basé sur les sessions fait en sorte que le serveur lit la session depuis Postgres, puis la transmet au gestionnaire de route :
Client --> [Session cookie] --> Server
|
Lookup session in Postgres
|
Route handler
Une seule recherche dans une source unique de vérité signifie qu’en cas de problème, il n’y a qu’un seul endroit à vérifier.
Où les JWT restent pertinents
Rien de tout cela ne rend les JWT mauvais. Ils conviennent parfaitement aux jetons à durée de vie courte et à usage spécifique : liens de vérification d’e-mail, flux de réinitialisation de mot de passe, ainsi que d’autres preuves d’intention autonomes qui expirent en quelques minutes et n’ont jamais besoin d’être annulées en cours d’utilisation. Un jeton de vérification comme celui ci-dessous contient l’ID de l’utilisateur et son usage, et expire après 15 minutes :
// email verification — expires fast, never needs revocation
function makeVerifyToken(userId) {
return jwt.sign({ userId, purpose: "verify" }, process.env.SECRET, {
expiresIn: "15m"
});
}
L’inclusion d’une indication de but est importante, car elle empêche qu’un token de vérification ne soit accepté là où un autre type de token est attendu. Gardez à l’esprit qu’un JWT seul ne peut pas garantir un usage unique ; si un lien de réinitialisation doit fonctionner uniquement une fois, notez que celui-ci a été utilisé, ou liez le token à une valeur qui change après utilisation, comme le hachage du mot de passe actuel.
La spécification n’a jamais été le problème. Le problème réside dans son adaptation pour couvrir des sessions utilisateur persistantes, révocables et à longue durée de vie, ce pour quoi les sessions ont été conçues.
Pourquoi les équipes persistent avec des mécanismes d’authentification complexes
Les sessions semblent obsolètes tandis que les JWT paraissent modernes, et les tutoriels sur les JWT sont bien plus nombreux que ceux sur les sessions. Cet déséquilibre influence ce qui est considéré comme correct.
Les ingénieurs expérimentés ont souvent tendance à choisir la voie opposée et à opter pour des solutions ennuyeuses. De nombreux incidents de production leur ont appris que les systèmes simples ne déclenchent rarement d’alertes la nuit, et que les sessions côté serveur fonctionnent de manière fiable depuis des décennies, bien avant l’apparition de Redis.
Choix de votre approche
- Vous lancez une nouvelle application web ? Préférez les sessions côté serveur : un cookie
httpOnly,secure,sameSite, les sessions stockées dans Postgres, la session régénérée lors de la connexion et supprimée à la déconnexion. Pour une comparaison plus approfondie, consultez notre guide sur le choix entre sessions et JWTs. - Votre configuration JWT fonctionne bien ? Laissez-la telle quelle ; migrer uniquement pour le plaisir de changer augmente les risques.
La meilleure décision d’ingénierie est souvent celle qui permet de supprimer du code. Moins de lignes signifient moins d’endroits où peuvent apparaître des bugs, et moins de couches facilitent la détection des bugs restants.