Sécurité
JWT et rotation des jetons de rafraîchissement : la partie que l'on oublie souvent
Un jeton d'accès court est une bonne pratique. Sans rotation ni détection de réutilisation côté rafraîchissement, un jeton volé reste exploitable silencieusement pendant des semaines.
Les jetons JWT sont largement utilisés pour protéger les API, avec une règle désormais bien connue : un jeton d'accès de courte durée, et un jeton de rafraîchissement pour en obtenir de nouveaux. La partie la plus souvent négligée se situe dans le second : sa durée de vie, son stockage et son cycle de renouvellement.
Le problème des jetons de rafraîchissement longue durée
Un jeton de rafraîchissement valable trente jours et réutilisable à volonté est une clé presque permanente. S'il fuite, par un journal, un poste de travail compromis ou un stockage navigateur mal choisi, l'attaquant conserve l'accès jusqu'à l'expiration. Sans journalisation fine, personne ne s'en aperçoit.
Le principe : usage unique et détection de réutilisation
La rotation consiste à émettre un nouveau jeton de rafraîchissement à chaque utilisation et à invalider immédiatement l'ancien. Chaque jeton appartient à une famille, c'est-à-dire à une chaîne de renouvellements issue de la même authentification initiale.
Si un jeton déjà utilisé est présenté à nouveau, deux scénarios sont possibles : soit un rejeu réseau légitime, soit un vol. Traiter le cas comme un vol et révoquer toute la famille est la seule réponse raisonnable : l'utilisateur légitime devra se réauthentifier, l'attaquant perd son accès.
Ne jamais stocker le jeton en clair
Le jeton de rafraîchissement se stocke sous forme d'empreinte, jamais en clair. Une base de données lue par un attaquant ne doit pas permettre de rejouer une session.
create table refresh_token (
id uuid primary key,
family_id uuid not null,
user_id uuid not null,
token_hash char(64) not null unique,
issued_at timestamptz not null,
expires_at timestamptz not null,
used_at timestamptz,
revoked_at timestamptz
);
create index ix_refresh_token_family on refresh_token (family_id);
create index ix_refresh_token_user on refresh_token (user_id, expires_at);
Implémentation côté serveur
Le service de rafraîchissement vérifie l'empreinte, contrôle l'état du jeton, puis révoque et remplace. Les exceptions sont distinctes, car elles ne se traitent pas de la même façon : un jeton expiré mène à une réauthentification, une réutilisation détectée déclenche une alerte de sécurité.
@Service
class RefreshTokenService {
private final RefreshTokenRepository repository;
private final TokenIssuer tokenIssuer;
RefreshTokenService(RefreshTokenRepository repository, TokenIssuer tokenIssuer) {
this.repository = repository;
this.tokenIssuer = tokenIssuer;
}
@Transactional
public IssuedTokens rotate(String presentedToken, String deviceId) {
RefreshToken current = repository.findByHash(TokenHasher.sha256(presentedToken))
.orElseThrow(InvalidRefreshTokenException::new);
if (current.isUsed() || current.isRevoked()) {
repository.revokeFamily(current.familyId());
throw new TokenReuseDetectedException(current.userId(), deviceId);
}
if (current.isExpired()) {
throw new ExpiredRefreshTokenException();
}
repository.markUsed(current.id());
return tokenIssuer.issueForFamily(current.familyId(), current.userId());
}
}
Les opérations sont transactionnelles : marquer un jeton comme utilisé et en émettre un nouveau doit réussir ou échouer ensemble, sans quoi une panne en cours de route laisse deux jetons valides.
| Risque | Sans rotation | Avec rotation et détection |
|---|---|---|
| Jeton volé | Accès silencieux jusqu'à expiration | Accès révoqué à la première réutilisation |
| Détection de l'incident | Après coup, rarement | Événement de sécurité immédiat |
| Durée d'exposition | Jusqu'à trente jours | Quelques minutes à quelques heures |
| Révocation globale | Difficile sans stockage | Révocation par famille ou par utilisateur |
Un jeton de rafraîchissement à usage unique transforme un vol de jeton en incident détectable, au lieu d'un accès silencieux et permanent.
Le cas des navigateurs
Dans une application web, le jeton de rafraîchissement se place dans un cookie portant les attributs HttpOnly, Secure et SameSite=Strict, avec un chemin restreint au point d'entrée de rafraîchissement. Le stockage local du navigateur doit être évité : il est accessible à tout script exécuté sur la page, y compris par une dépendance compromise.
La protection contre la falsification de requête entre sites reste nécessaire, sous forme d'un jeton anti-CSRF lié à la session, vérifié sur le point d'entrée de rafraîchissement uniquement.
Ce qu'il faut vérifier avant la mise en production
Quatre points suffisent à couvrir l'essentiel : la durée de vie du jeton d'accès reste courte ; le jeton de rafraîchissement est à usage unique et stocké sous forme d'empreinte ; toute réutilisation révoque la famille et génère un événement de sécurité ; la déconnexion révoque explicitement la famille côté serveur, sans se contenter de supprimer le jeton du navigateur.
Articles associes
Architecture hexagonale avec Spring Boot 4 : ce que cela change vraiment
Créer trois dossiers ne suffit pas à faire une architecture hexagonale. Voici la règle de dépendance, le code qui la respecte et les tests qu'elle rend enfin possibles avec Spring Boot 4 et Java 21.