Vos utilisateurs sont déconnectés au hasard ? Votre rotation de refresh token se bat peut-être contre elle-même
Si vous avez activé la rotation des refresh tokens et que vous commencez à voir des utilisateurs déconnectés sans cause apparente — en particulier après des soucis réseau, plusieurs onglets ouverts, ou des appareils qui se réveillent de veille — vous n’êtes probablement pas face à une attaque. Vous êtes probablement face à votre propre client qui entre en compétition avec lui-même, et votre authorization server qui traite correctement (mais de façon peu pratique) cette situation comme un rejeu potentiel.
C’est l’un des problèmes d’intégration les plus courants rencontrés par les équipes après l’activation de la rotation, et il est rarement traité honnêtement : la plupart des guides expliquent pourquoi la rotation est une bonne pratique de sécurité et s’arrêtent là, vous laissant déboguer les conséquences seul. Cet article traite justement de ces conséquences — ce qui se passe réellement, comment le distinguer d’un véritable incident de sécurité, et des solutions concrètes pour les SPA, les applications mobiles et les applications traditionnelles côté serveur utilisant des cookies.
Rappel rapide : ce que fait réellement la rotation des refresh tokens
Un refresh token est un identifiant de longue durée que votre client utilise pour obtenir de nouveaux access tokens sans forcer l’utilisateur à se reconnecter. Les access tokens ont une durée de vie courte (souvent 15 à 60 minutes) ; les refresh tokens peuvent vivre plusieurs jours ou semaines.
La rotation change une chose : au lieu qu’un refresh token reste valide pendant toute sa durée de vie, chaque utilisation le consomme. Chaque fois que le client appelle le token endpoint, l’authorization server émet un nouvel access token et un nouveau refresh token, et invalide celui qui vient d’être utilisé.
Le comportement de base des refresh tokens provient de la RFC 6749 §6, qui pose déjà les bases de l’expiration, de la révocation et de la rotation. La référence actuelle et faisant autorité sur la façon de procéder de manière sécurisée est la RFC 9700, OAuth 2.0 Security Best Current Practice (BCP 240, janvier 2025). Sa section 4.14.2 formule la recommandation directement :
Refresh token rotation: the authorization server issues a new refresh token with every access token refresh response. The previous refresh token is invalidated, but information about the relationship is retained by the authorization server.
Pourquoi la rotation existe
Sans rotation, un refresh token est un identifiant de longue durée réutilisable indéfiniment. S’il est exfiltré, un attaquant peut émettre de nouveaux access tokens aussi longtemps que le refresh token reste valide — souvent plusieurs semaines. La RFC 9700 §4.14.1 le dit clairement : les refresh tokens sont une cible privilégiée précisément parce qu’ils représentent l’étendue complète des accès accordés à un client et ne sont pas restreints à une ressource spécifique. Un attaquant qui exfiltre et rejoue un tel token peut émettre des access tokens et agir au nom du resource owner indéfiniment.
La rotation comble cette faille et donne à l’authorization server un signal de détection qu’il n’avait pas auparavant. Voici le mécanisme tel que le BCP le décrit réellement, à lire attentivement car la précision compte :
If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach. The authorization server cannot determine which party submitted the invalid refresh token, but it will revoke the active refresh token. This stops the attack at the cost of forcing the legitimate client to obtain a fresh authorization grant.
Deux points méritent d’être soulignés dans ce texte, car ils sont souvent aplatis dans beaucoup d’articles secondaires :
- Le serveur ne peut pas distinguer l’attaquant du client légitime. Il voit deux présentations d’un token invalidé et n’a aucun moyen de savoir laquelle était malveillante. C’est exactement pourquoi les races causées par votre propre application sont indiscernables d’un vol, du point de vue du serveur.
- Le BCP décrit la révocation du « active refresh token », pas un effacement dramatique et systématique de tous les identifiants jamais détenus par l’utilisateur. Les implémentations varient dans l’étendue de la révocation — certaines invalident toute la chaîne des descendants d’un grant, d’autres sont plus restreintes — mais le texte de la RFC lui-même est plus mesuré que le discours « tout supprimer » que l’on trouve ailleurs. Vérifiez la documentation de votre fournisseur spécifique pour savoir ce qui se passe réellement de son côté quand une réutilisation est détectée, plutôt que de supposer un rayon d’action particulier.
Ce mécanisme de détection est probablement exactement ce qui déconnecte vos utilisateurs.
Pourquoi la « réutilisation » se déclenche sur votre propre trafic légitime
La rotation suppose un passage de relais propre et séquentiel : utiliser le token A, obtenir le token B, jeter le token A, répéter. Les applications réelles ne se comportent pas aussi proprement, et chaque écart par rapport à cette séquence peut sembler, du point de vue de l’authorization server, indiscernable d’un vol.
Appels de refresh concurrents. Deux parties de votre application — deux onglets, un processus de synchronisation en arrière-plan et une requête au premier plan, deux appareils — détectent un access token expiré à peu près au même moment et appellent indépendamment le token endpoint avec le même refresh token. Le serveur ne peut honorer qu’une seule de ces requêtes.
La réponse qui n’arrive jamais. Le client envoie un refresh token valide. Le serveur le fait tourner avec succès — invalide l’ancien, en génère un nouveau — mais la réponse n’atteint jamais le client : connexion interrompue, application mise en arrière-plan en cours de requête, processus tué. Le client se retrouve avec un refresh token déjà mort côté serveur, sans aucun moyen de distinguer cette situation de « la requête n’a jamais atteint le serveur ».
Processus indépendants, aucun état partagé. Tout ce qui compte plusieurs endroits où les tokens peuvent être rafraîchis, sans coordination entre ces endroits, finira par entrer en collision.
Aucun de ces cas ne nécessite un attaquant. Ce sont la cause réelle et peu glorieuse derrière la plupart des tickets « pourquoi cet utilisateur a-t-il été déconnecté » une fois la rotation activée — et la solution diffère selon le type d’application que vous exploitez.
Comment distinguer une véritable attaque d’un faux positif
Avant de supposer que chaque événement de réutilisation est un bug dans votre propre client :
- Timing : une race légitime produit généralement une réutilisation dans un intervalle de quelques millisecondes à quelques secondes après le refresh initial. Un attaquant rejouant un token capturé plus tôt tend à produire un écart plus long et moins resserré dans le temps.
- Origine : si vous journalisez l’adresse IP, le user agent, ou l’empreinte de l’appareil sur les requêtes de refresh, une race provenant de votre propre client montre le même contexte des deux côtés. Un rejeu de token volé en montre généralement un différent.
- Fréquence : des erreurs de réutilisation occasionnelles corrélées à des conditions connues (réseau instable, plusieurs onglets) suggèrent des races. Un schéma soutenu sur de nombreux utilisateurs, ou une réutilisation répétée sur un seul compte dans la durée, est un signal plus fort d’une véritable compromission.
Vous n’avez pas besoin d’un système complet de détection d’anomalies pour en tirer parti — journaliser suffisamment pour observer le schéma a posteriori vous dira rapidement si vous êtes face à un bug d’intégration ou à un incident de sécurité.
Corriger les faux positifs, par type d’application
La bonne solution dépend fortement de l’endroit où vit votre refresh token et du nombre d’endroits pouvant tenter de l’utiliser simultanément. L’idée centrale est la même partout — sérialiser l’accès au refresh token pour qu’un seul refresh soit en cours à la fois — mais le mécanisme diffère.
Single-Page Applications (SPA)
Dans une SPA en navigateur, le refresh token (ou la session qui en tient lieu, si vous utilisez un Backend-for-Frontend) est partagé entre tous les onglets ouverts et tous les appels API concurrents au sein d’un onglet. C’est l’environnement où les races sont les plus fréquentes, car rien n’empêche deux onglets de se réveiller et de rafraîchir au même instant.
La solution comporte deux niveaux :
- Verrou single-flight par onglet. Tout chemin de code ayant besoin d’un token — un retry déclenché par un 401, un refresh proactif avant expiration — doit vérifier s’il existe une promesse de refresh en cours et l’attendre plutôt que de démarrer une nouvelle requête. C’est un pattern de promesse/mutex partagé, standard dans les SDK d’authentification matures.
- Coordination inter-onglets. Un verrou à l’intérieur d’un onglet n’empêche pas un autre onglet d’entrer en collision avec lui. Utilisez
BroadcastChannel(ou un écouteur d’événementstorageen repli) pour que, lorsqu’un onglet termine une rotation, les onglets voisins reçoivent le nouveau token au lieu de tenter leur propre refresh avec un token sur le point d’être remplacé.

Applications mobiles (natives iOS / Android)
Les applications mobiles ont généralement un seul processus, mais les races viennent ici d’une source différente : l’exécution en arrière-plan, les transitions réseau (Wi-Fi vers cellulaire), et l’OS qui suspend ou tue l’application en cours de requête. Un refresh peut réussir côté serveur alors que l’application ne reçoit jamais la réponse, car le processus a été terminé en arrière-plan avant de pouvoir persister le nouveau token.
Les mitigations :
- Un verrou single-flight au niveau du processus — même principe que pour la SPA, mais plus simple puisqu’il n’y a généralement qu’un seul processus, pas plusieurs onglets. Protégez l’appel de refresh avec un mutex/sémaphore afin que les requêtes concurrentes (par exemple, plusieurs appels API échouant en 401 simultanément) partagent une seule tentative de refresh.
- Traiter « aucune réponse reçue » comme ambigu, pas comme fatal. Si une requête de refresh a été envoyée mais que l’application a été suspendue avant qu’une réponse n’arrive, ne supposez pas que l’ancien refresh token est mort — la requête a peut-être échoué, ou la réponse n’a peut-être pas été traitée. Au prochain lancement, tentez un refresh avec le dernier token connu avant de retomber sur une connexion complète ; ne considérez qu’une erreur explicite
invalid_grant/réutilisation du serveur comme confirmation que le token a disparu. - Persister le nouveau token de manière atomique. N’écrivez le nouveau refresh token dans le stockage sécurisé (Keychain / Keystore) qu’après avoir confirmé que la réponse complète a été reçue et parsée, afin qu’une écriture partielle ne vous laisse pas avec un token qui ne correspond pas à ce que le serveur détient.

Applications traditionnelles rendues côté serveur (token dans un cookie HTTP-only)
Ici, le refresh token vit généralement côté serveur ou dans un cookie HTTP-only géré par votre backend, et non dans du JavaScript côté client. Les races se produisent quand même, mais la source est généralement plusieurs requêtes simultanées provenant de la même session navigateur — une page effectuant plusieurs appels API en parallèle, ou un utilisateur double-cliquant/soumettant deux fois — chacune déclenchant indépendamment un refresh côté serveur pour la même session.
Les mitigations :
- Sérialiser le refresh au niveau de la session, côté serveur. Utilisez un verrou par session (un mutex de courte durée dans Redis, un verrou de ligne en base de données, ou un verrou en mémoire si vous êtes en instance unique) afin que si deux requêtes pour la même session détectent toutes deux un access token expiré, une seule appelle réellement l’authorization server ; l’autre attend ce résultat et le réutilise.
- Faire de la question du grace period une décision de configuration côté serveur, et non quelque chose que chaque requête doit gérer elle-même — c’est le niveau où c’est le plus simple à appliquer, puisqu’il n’y a qu’un seul endroit (votre backend) qui effectue les appels de refresh, et non de nombreux clients indépendants.
- Gérer les requêtes concurrentes issues du même chargement de page. Le rendu d’une seule page qui déclenche plusieurs appels backend en parallèle peut provoquer la même race en interne, même avec un seul onglet navigateur. Le verrou au niveau session ci-dessus couvre aussi ce cas, car il n’est pas spécifique à un onglet — il s’agit d’empêcher deux requêtes de solliciter le token endpoint pour la même session simultanément.

Distinguer l’échec transitoire de l’invalidation confirmée
Pour les trois types d’applications, une erreur cause plus de déconnexions inutiles que toute autre chose : traiter chaque échec de refresh comme une preuve que le token est mort. Votre gestion des erreurs doit comporter au moins deux branches :
- Transitoire (timeout, 5xx, connexion interrompue) : retentez avec un backoff, et ne jetez pas le refresh token. Si la requête n’a jamais atteint le serveur, ou si la réponse n’est jamais revenue, le token peut encore être valide.
- Terminal (
invalid_grant, une erreur explicite de réutilisation/expiration du fournisseur) : le token est confirmé mort. C’est seulement à ce moment-là qu’il faut effacer l’état local et retomber sur une ré-authentification complète.
Solution : vérifier si votre fournisseur propose un grace period de réutilisation
En complément du verrouillage au niveau applicatif décrit ci-dessus, il existe une solution qui vit entièrement côté fournisseur et qui peut réduire les faux positifs sans aucune modification côté client : un grace period de réutilisation. Certains authorization servers assouplissent la rotation avec une courte fenêtre de grâce — au lieu d’invalider l’ancien refresh token à l’instant même où un nouveau est émis, ils autorisent sa réutilisation pendant quelques secondes et renvoient la même nouvelle paire de tokens plutôt que d’en émettre une nouvelle. Cela absorbe les requêtes quasi simultanées sans affaiblir le modèle de sécurité, puisque la fenêtre est courte et la réponse idempotente.
Le support de cette fonctionnalité varie fortement selon le fournisseur, et elle est souvent désactivée par défaut. nacho.cerberauth.com/openid/providers recense la façon dont différents fournisseurs OpenID gèrent la rotation et les grace periods, afin que vous puissiez vérifier si le vôtre la prend en charge avant de construire une solution de contournement côté client pour quelque chose que le fournisseur résout peut-être déjà.
Quand il s’agit d’un véritable événement de réutilisation, et non d’une race
Une fois les causes ci-dessus écartées, certains événements de réutilisation seront réels. Lorsque votre client reçoit une erreur confirmée de réutilisation/invalid-grant, ne retentez pas avec le même token et n’essayez pas de récupérer discrètement la session — effacez l’état local des tokens et redirigez l’utilisateur vers une ré-authentification complète, conformément à ce que la RFC 9700 §4.14.2 décrit comme résultat attendu : le client légitime est contraint d’obtenir un nouvel authorization grant. Ce qui est exactement révoqué côté serveur (uniquement ce token, ou un ensemble plus large) dépend de l’implémentation de votre fournisseur — ne présumez pas d’un comportement spécifique que votre fournisseur n’a pas documenté.
Checklist de diagnostic
Si vous traquez des déconnexions inexpliquées sur un système avec la rotation activée :
- Reproduisez-la. Corrélez les déconnexions avec plusieurs onglets, des requêtes parallèles, ou de mauvaises conditions réseau — un signe fort que vous êtes face à une race, pas à une attaque.
- Vérifiez la présence d’un verrou single-flight adapté à votre architecture — côté client pour SPA/mobile, au niveau session pour les applications rendues côté serveur. C’est très probablement votre cause racine si absent.
- Vérifiez le support du grace period de votre fournisseur sur nacho.cerberauth.com/openid/providers — l’activer peut résoudre une part importante des faux positifs sans aucun changement côté client.
- Auditez votre gestion des erreurs pour la distinction transitoire/terminal décrite plus haut.
- Pour le mobile : assurez-vous de ne pas jeter un token potentiellement encore valide simplement parce qu’une réponse n’a jamais été reçue.
- Confirmez que votre chemin de ré-authentification est propre pour les événements de réutilisation qui sont authentiques — une redirection claire vers la connexion, pas un échec silencieux ou une boucle de retry.
À retenir
Si la rotation cause des déconnexions que vous ne pouvez pas expliquer, la solution n’est pas d’affaiblir la rotation — l’argument de sécurité en sa faveur, exposé dans la RFC 9700, est réel, et le mécanisme qu’elle décrit est délibérément imprécis quant à la distinction entre attaquant et client légitime, pour une bonne raison : le serveur ne peut pas toujours le savoir, et forcer une ré-authentification est le choix par défaut le plus sûr. La solution consiste à construire la coordination dont votre architecture a besoin : un verrou single-flight (à l’échelle du navigateur, du processus, ou de la session, selon votre application), un grace period là où votre fournisseur le permet, et une gestion des erreurs qui sait faire la différence entre « le réseau a échoué » et « ce token est mort ». Une fois cela en place, les événements de réutilisation qui subsistent sont ceux qui méritent réellement d’être investigués.