security
Révoquer un jeton qui est sans état par conception
Révoquer un jeton qui est sans état par conception
L'attrait d'un JWT, c'est que le valider ne nécessite aucun accès à la base — la signature est la preuve. Le hic est dans la même phrase : une fois que j'en ai signé un, je ne peux pas le reprendre. Si quelqu'un change son mot de passe, se fait verrouiller, ou qu'un admin le force à se déconnecter, une config JWT simple ne fait exactement rien. Le jeton reste cryptographiquement valide jusqu'à ce qu'il expire tout seul.
Je l'ai trouvé de la façon peu glorieuse. Un compte qui aurait dû être déconnecté continuait de marcher. Je ne l'ai remarqué qu'en mettant la croyance du front-end sur l'état d'authentification à côté des logs de requête du back-end et en les voyant en désaccord — l'application pensait le compte déconnecté, le back-end honorait toujours le jeton, parce que rien nulle part ne vérifiait si le jeton devait encore être de confiance, seulement si la signature était valide.
Les options que j'ai pesées
- Une liste noire Redis d'identifiants de jetons révoqués. Ça marche, mais ça grossit à chaque déconnexion et verrouillage, et maintenant je gère des TTL pour qu'elle ne grossisse pas indéfiniment ni n'expire trop tôt.
- Des jetons d'accès de courte durée avec rotation de rafraîchissement. Bonne hygiène, ne résout pas la révocation instantanée — un jeton d'accès encore valide est utilisable pendant toute sa durée de vie.
- Un compteur de version sur la ligne utilisateur. Ce que j'ai choisi.
// à la connexion / émission du jeton
const token = await signJwt({ sub: user.id, tv: user.token_version });
// à chaque requête, dans le guard d'authentification
const payload = await verifyJwt(token);
const currentUser = await db.users.findUnique({ where: { id: payload.sub } });
if (currentUser.token_version !== payload.tv) {
throw new UnauthorizedException("Session no longer valid");
}
Pour tuer chaque session que détient un utilisateur — partout, d'un coup — j'incrémente un entier :
await db.users.update({
where: { id: userId },
data: { token_version: { increment: 1 } },
});
Pas de croissance non bornée, pas de comptabilité de TTL, pas de store de révocation séparé à garder synchro. Le prix est une lecture en base par requête authentifiée — ou une lecture en cache via Redis, invalidée quand le compteur s'incrémente — ce qui est un coût borné et prévisible au lieu d'une liste grandissante.
La leçon qui est restée
Décide l'histoire du « comment on force la déconnexion de quelqu'un maintenant » avant d'écrire l'endpoint de connexion, pas après avoir trouvé le trou en test. Ça appartient au premier jour de tout système d'authentification, comme la réinitialisation de mot de passe ou la limitation de débit. Le temps que tu remarques qu'il manque, c'est généralement parce que quelque chose est déjà passé par là.