Guide pratique de l'authentification par JWT
Les JSON Web Tokens sont adoptés par défaut dans beaucoup de nouveaux backends, souvent sans que personne ne vérifie qu'un jeton est réellement le bon choix, ni ce qui se passe le jour où il faut en révoquer un. Voici ce qu'est réellement un JWT, comment bien l'utiliser, et où il ne convient vraiment pas.
Ce qu'il y a réellement dans un JWT
Un JWT est composé de trois segments encodés en base64url, joints par des points : header.payload.signature. Seule la signature dépend d'un secret : l'en-tête et le contenu (payload) sont simplement encodés, pas chiffrés, et n'importe qui peut les décoder et les lire.
// header
{ "alg": "HS256", "typ": "JWT" }
// payload (the "claims")
{ "sub": "user_4821", "role": "admin", "exp": 1774300800 }
// signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
Ce dernier point mérite d'être répété, car il piège constamment les gens : ne mettez jamais rien de secret dans le payload. Un JWT prouve que le payload n'a pas été altéré depuis sa signature ; il ne cache pas le payload au détenteur du jeton ni à quiconque à qui on le montre.
Émettre un jeton
import jwt from 'jsonwebtoken';
function issueToken(user) {
return jwt.sign(
{
sub: user.id,
role: user.role,
},
process.env.JWT_SECRET,
{ expiresIn: '15m' },
);
}
expiresIn fixe la revendication exp : le jeton devient structurellement invalide à la vérification une fois ce délai écoulé, sans que le serveur ait rien à suivre. Cette absence d'état est tout l'attrait des JWT : n'importe quel serveur possédant le secret partagé (ou la clé publique, pour une signature asymétrique) peut vérifier un jeton sans recherche en base ni appel à un magasin de sessions.
Vérifier un jeton
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'missing_token' });
}
try {
const payload = jwt.verify(authHeader.slice(7), process.env.JWT_SECRET);
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
return res.status(401).json({ error: 'invalid_token' });
}
}
jwt.verify contrôle la signature et l'expiration en un seul appel, en levant une exception si l'une des deux échoue. Laissez toujours la bibliothèque s'en charger : comparer soi-même les signatures est un moyen facile d'introduire une attaque temporelle ou un bug de confusion de types (une classe de vulnérabilités JWT bien réelle impliquait des serveurs qui faisaient confiance au champ alg du jeton lui-même, permettant à un attaquant de passer à alg: none et de sauter entièrement la vérification ; les bibliothèques modernes exigent de spécifier explicitement les algorithmes acceptés plutôt que de croire ce que le jeton affirme sur son propre algorithme).
Le problème de la révocation
L'absence d'état est l'argument de vente des JWT, mais aussi leur arête la plus vive : si vous ne vérifiez jamais rien en base, vous ne pouvez pas non plus invalider un jeton précis avant son expiration. Un utilisateur change de mot de passe, se déconnecte ou est banni, et son jeton existant continue de fonctionner jusqu'à l'arrivée de exp, car rien dans « vérifier la signature » ne revient à demander « ce jeton doit-il encore être honoré ? ».
Trois façons courantes d'y faire face, par ordre croissant d'abandon de l'absence d'état :
- Expiration courte + jetons de rafraîchissement. Émettre un jeton d'accès de courte durée (quelques minutes) et un jeton de rafraîchissement plus durable, suivi en base. Révoquer l'accès consiste à supprimer l'enregistrement du jeton de rafraîchissement ; le jeton d'accès lui-même fonctionne encore jusqu'à son expiration naturelle, mais cette fenêtre est petite.
// on login
const accessToken = jwt.sign({ sub: user.id }, secret, { expiresIn: '15m' });
const refreshToken = crypto.randomUUID();
await db.refreshTokens.insert({ token: refreshToken, userId: user.id, expiresAt: /* 30 days */ });
// on refresh
async function refresh(refreshToken) {
const record = await db.refreshTokens.findValid(refreshToken);
if (!record) throw new Error('invalid_refresh_token');
return jwt.sign({ sub: record.userId }, secret, { expiresIn: '15m' });
}
- Une liste de refus pour les cas rares et urgents. Conserver un petit magasin rapide à consulter (Redis, avec un TTL correspondant à la durée de vie restante du jeton) des jetons à rejeter immédiatement (un jeton volé, un bannissement d'urgence), vérifié à chaque requête en plus de la vérification de signature. Cela réintroduit une recherche sur le chemin critique, mais seulement pour le cas exceptionnel, et non pour chaque jeton jamais émis.
- Utilisez simplement des sessions. Si vous construisez une liste de refus qui couvre la plupart des jetons la plupart du temps, vous avez réinventé les sessions côté serveur avec des étapes en plus. Si la révocation instantanée compte beaucoup et que votre trafic n'a pas besoin de l'absence d'état entre services des JWT, un identifiant de session classique adossé à une base ou à Redis est plus simple et fait la seule chose pour laquelle les JWT sont structurellement mauvais.
Là où le stockage vous pique
Si le jeton d'accès vit dans localStorage, il est lisible par tout JavaScript exécuté sur la page, y compris un script injecté via une faille XSS ailleurs dans l'application. Un cookie httpOnly ne peut pas être lu du tout par JavaScript, ce qui comble ce trou précis, mais ouvre la voie au CSRF, puisque les navigateurs joignent automatiquement les cookies aux requêtes, quel que soit le site qui les a déclenchées.
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
sameSite: 'strict' (ou au minimum 'lax') est ce qui atténue réellement le CSRF ici : il empêche d'emblée le navigateur de joindre le cookie aux requêtes issues d'un autre site.
Quand le JWT est le mauvais outil
- Un monolithe unique qui interroge de toute façon sa propre base à chaque requête. Vous payez le coût de complexité du JWT (stratégie de révocation, gestion des clés de signature) pour un bénéfice d'absence d'état que vous n'utilisez pas, puisqu'une recherche de session aurait touché la base que vous touchez déjà.
- Tout ce qui exige de déconnecter un utilisateur partout, tout de suite, comme exigence stricte : réinitialisation de mot de passe, compromission de compte. Prévoyez une liste de refus ou la révocation de jetons de rafraîchissement dès le premier jour plutôt que de découvrir la lacune pendant un incident.
À retenir
Un JWT est une affirmation signée, non chiffrée, sur une identité qu'un serveur peut vérifier sans aller-retour vers une base : réellement utile entre services, là où un magasin de sessions partagé serait malcommode. Le compromis est que la révocation n'est pas gratuite : décidez d'emblée si vous avez besoin de jetons de courte durée avec rafraîchissement, d'une liste de refus pour les urgences, ou si vous n'avez en réalité pas besoin de l'absence d'état des JWT.