Déconnectez un utilisateur d’une application basée sur les sessions, et le serveur l’oublie dès la requête suivante. Déconnectez-le d’une application basée sur JWT et, à moins d’avoir mis en place un mécanisme supplémentaire, le token qu’il détient reste valide jusqu’à son expiration. Cette différence est la vraie décision derrière « JWT contre session ». Tout le reste en découle.

Ce qu’une session stocke réellement

Une session garde l’état de l’utilisateur sur le serveur et ne transmet au navigateur qu’une clé de référence.

  1. L’utilisateur se connecte avec un mot de passe.
  2. Le serveur crée un enregistrement de session (ID utilisateur, rôles, tout ce qu’il lui faut) et le stocke en mémoire, dans une base de données ou sur Redis, indexé par un ID de session aléatoire.
  3. Le serveur envoie cet ID au navigateur sous forme de cookie, marqué HttpOnly pour que le JavaScript côté client ne puisse pas le lire.
  4. À chaque requête, le navigateur renvoie automatiquement le cookie. Le serveur recherche l’ID dans le store et récupère les données de session.

Le cookie ne transporte aucune information sur l’utilisateur, seulement une chaîne opaque que le serveur résout à chaque requête. Supprimez l’enregistrement dans le store et la session disparaît dès la requête suivante, sur tous les appareils.

Ce qu’il y a à l’intérieur d’un JWT

Un JSON Web Token évite cette recherche en plaçant l’état directement dans le token, signé pour qu’il ne puisse pas être altéré.

Un JWT se compose de trois parties encodées en base64url et séparées par des points : header.payload.signature. Le payload est un objet JSON de claims. La RFC 7519 définit une courte liste de claims enregistrés :

ClaimNomSignification
issIssuerQui a émis le token
subSubjectDe qui parle le token
audAudienceÀ qui le token est destiné
expExpirationRejeter le token après ce moment
nbfNot beforeRejeter le token avant ce moment
iatIssued atQuand le token a été créé
jtiJWT IDID unique, utile pour la révocation et la détection de replay

Le serveur signe le header et le payload avec un secret (HMAC) ou une clé privée (RSA/ECDSA) et envoie le résultat au client. N’importe quel serveur possédant la clé correspondante (ou, pour une signature asymétrique, seulement la clé publique) peut vérifier la signature et faire confiance aux claims. Aucun aller-retour vers la base de données, aucun store de sessions partagé. Dans un système à plusieurs services, chacun peut vérifier un token de son côté.

Le revers de la médaille : quiconque détient le token peut lire le payload. Le base64 est un encodage, pas un chiffrement. Ne mettez jamais un mot de passe, un secret ou des données personnelles que vous n’imprimeriez pas dans un log dans le payload d’un JWT.

Comment configurer l’authentification par session dans Express

Une configuration Node classique utilise express-session comme middleware et connect-redis pour que les sessions survivent à un redémarrage du serveur et fonctionnent sur plusieurs instances. Si Redis est nouveau pour vous, comment fonctionne le cache Redis couvre les bases pour le faire tourner.

1
npm install express express-session connect-redis redis
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const express = require("express");
const session = require("express-session");
const { RedisStore } = require("connect-redis");
const { createClient } = require("redis");

const redisClient = createClient({ url: "redis://localhost:6379" });
redisClient.connect().catch(console.error);

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

app.use(
  session({
    store: new RedisStore({ client: redisClient, prefix: "sess:" }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 1000 * 60 * 60 * 2, // 2 heures
    },
  })
);

app.post("/login", async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  req.session.userId = user.id;
  res.sendStatus(204);
});

app.get("/me", (req, res) => {
  if (!req.session.userId) return res.sendStatus(401);
  res.json({ userId: req.session.userId });
});

app.post("/logout", (req, res) => {
  req.session.destroy(() => res.sendStatus(204));
});

app.listen(3000);

req.session.destroy() supprime l’entrée dans Redis. La requête suivante avec ce cookie ne trouve rien et est traitée comme une déconnexion. Pas de liste noire de tokens, rien à retoucher plus tard.

Comment configurer l’authentification JWT dans Node

Le même flux avec jsonwebtoken, en émettant un access token de courte durée et un refresh token séparé :

1
npm install express jsonwebtoken
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const crypto = require("crypto");
const express = require("express");
const jwt = require("jsonwebtoken");

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

function issueTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: "15m", algorithm: "HS256" }
  );
  const refreshToken = jwt.sign(
    { sub: user.id, jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: "7d", algorithm: "HS256" }
  );
  return { accessToken, refreshToken };
}

function requireAuth(req, res, next) {
  const header = req.headers.authorization || "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.sendStatus(401);

  try {
    req.user = jwt.verify(token, process.env.JWT_ACCESS_SECRET, {
      algorithms: ["HS256"],
    });
    next();
  } catch (err) {
    return res.sendStatus(401);
  }
}

app.post("/login", async (req, res) => {
  const user = await authenticate(req.body.email, req.body.password);
  res.json(issueTokens(user));
});

app.get("/me", requireAuth, (req, res) => {
  res.json({ userId: req.user.sub, role: req.user.role });
});

app.listen(3000);

jwt.verify vérifie la signature et les claims exp/nbf en un seul appel, et lève une exception si l’un des deux échoue. C’est tout le contrôle d’authentification, sans aucun store impliqué.

L’option algorithms: ["HS256"] compte. Sans elle, un token signé avec un algorithme différent de celui prévu pourrait quand même passer, ce qui explique comment plusieurs bibliothèques JWT se sont retrouvées avec des vulnérabilités d’« alg confusion ». Fixez en dur la liste d’algorithmes acceptés à la vérification et rejetez systématiquement alg: "none".

Le refresh token est ce qui rend vivable un access token de 15 minutes. Le client le conserve et, quand l’access token expire, l’échange contre une nouvelle paire. Stockez le jti du refresh token côté serveur pour pouvoir révoquer cette seule chaîne sans toucher aux access tokens déjà en circulation.

Pourquoi les JWT sont difficiles à révoquer

Une session est un enregistrement dans un store : supprimez-le et l’utilisateur est déconnecté partout. Un JWT est un claim autonome que n’importe quel vérificateur accepte jusqu’à ce que exp dise le contraire. Se déconnecter, bannir un utilisateur ou rétrograder son rôle ne change rien à un token déjà émis, car le serveur qui vérifie n’a aucun enregistrement à supprimer.

Les mitigations habituelles :

  • Durée de vie courte de l’access token. 15 à 30 minutes est la plage la plus courante, pour qu’un token volé ou périmé n’ait qu’une petite fenêtre.
  • Rotation du refresh token. Chaque refresh émet un nouveau refresh token et invalide l’ancien ; réutiliser un ancien refresh token révoque toute la chaîne. OAuth 2.1 l’exige (ou des tokens sender-constrained) pour les clients publics.
  • Une liste noire ciblée. Pour tuer un token précis avant son expiration, indexez la liste noire sur jti plus iss, pas sur la chaîne brute du token. Le OWASP JWT Cheat Sheet explique pourquoi mettre la chaîne elle-même en liste noire n’est pas fiable. Une liste noire vérifiée à chaque requête remet aussi une recherche en base de données à chaque requête, exactement le coût que les JWT étaient censés supprimer.

Si votre application finit par faire une recherche à chaque vérification de toute façon, une session aurait été le choix le plus simple.

Où stocker un JWT dans le navigateur

Dans une application navigateur, placez le token dans un cookie HttpOnly et Secure, pas dans localStorage. N’importe quel script exécuté sur la page peut lire localStorage, donc une seule faille XSS livre tous les tokens qu’il contient. Le coût : une fois que le token voyage dans un cookie, le navigateur l’envoie automatiquement, et vous voilà de retour à vous défendre contre le CSRF avec SameSite et des tokens CSRF, exactement comme avec les sessions. La comparaison complète se trouve dans LocalStorage, SessionStorage ou Cookies : que choisir ?.

Quand utiliser les sessions et quand utiliser les JWT

SituationUtilisez
Une seule application rendue côté serveur, un seul backendSessions : plus simples, révocation immédiate
Déconnexion instantanée ou changements de permissions instantanésSessions, ou JWT avec une liste noire sur jti
Plusieurs services backend qui vérifient l’utilisateur chacun de son côtéJWT : aucun store de sessions partagé à solliciter
Application mobile ou clients API tiersJWT : les bearer tokens correspondent à une API HTTP sans état

Aucun des deux n’est plus sûr dans l’absolu. Une session sur Redis et un JWT de 15 minutes avec des refresh tokens rotés tiennent tout aussi bien. La différence est architecturale : combien de services doivent faire confiance au claim, et à quelle vitesse vous devez pouvoir le retirer.

Vous construisez une application avec un seul backend ? Choisissez les sessions par défaut et obtenez la révocation gratuitement. Passez aux JWT le jour où un second service doit savoir qui est l’utilisateur sans rappeler le service qui l’a authentifié.

Articles associés