Prévenir les conditions de course lors du rafraîchissement de tokens dans les applications monopage
Les utilisateurs se font déconnecter sans explication. Le token de session semble valide dans localStorage, la dernière requête réussie s'est produite il y a quelques secondes, et le pattern est frustrant de par son incohérence — sauf qu'il tend à se regrouper autour des chargements de pages qui déclenchent plusieurs appels API concurrents. C'est l'un des bugs de production les plus déroutants dans les applications monopage, et la cause principale est presque toujours la même : une condition de course lors du rafraîchissement de token.
Le mécanisme est simple une fois identifié. Les JWT expirent. Lorsqu'un token expire en cours de session, le comportement correct est d'échanger le refresh token contre un nouveau token d'accès et de réessayer la requête originale de manière transparente. Mais en réalité, les applications React font rarement une requête à la fois. Une page de tableau de bord peut simultanément récupérer des données utilisateur, un résumé de compte, des compteurs de notifications et une activité récente — tous au montage du composant, tous en parallèle. Si le token d'accès expire pendant cette rafale, toutes ces requêtes reçoivent un 401. Toutes détectent indépendamment le token expiré. Toutes tentent d'appeler l'endpoint de rafraîchissement. Le premier appel réussit et l'ancien refresh token est consommé. Le deuxième appel tente le même refresh token — désormais invalidé — et échoue. L'utilisateur est déconnecté.
Pourquoi ça se manifeste rarement en développement
La condition de course nécessite une coïncidence spécifique : plusieurs requêtes véritablement concurrentes, combinées avec une expiration de token se produisant exactement au mauvais moment. Les environnements de développement ont généralement des durées de vie de token généreuses, la latence localhost est quasi nulle donc les requêtes se terminent rapidement et se chevauchent rarement complètement, et la plupart des développeurs testent une fonctionnalité à la fois plutôt que de charger des pages riches en données. Le résultat est que ce bug se cache confortablement en développement et se manifeste en production sous des patterns d'utilisation réels — affectant le plus souvent les utilisateurs qui ont été actifs assez longtemps pour que leur token initial expire en cours de session.
Le reproduire délibérément nécessite de raccourcir les durées de vie des tokens et de naviguer vers des pages avec une récupération de données parallèle. Avec Supabase, le paramètre jwt_expiry dans la configuration de votre projet contrôle la durée de vie du token d'accès. Mettez-le à soixante secondes, ouvrez une page de tableau de bord avec plusieurs requêtes, et attendez. La condition de course apparaîtra dans les premières minutes d'utilisation active.
Comment ça se manifeste avec l'auth Supabase
Le client @supabase/supabase-js v2 de Supabase gère le rafraîchissement de token en interne et inclut une logique de sérialisation — il maintient une seule promesse de rafraîchissement en vol et met en file d'attente les appelants concurrents. Pour les applications qui utilisent exclusivement le client Supabase pour tous les appels API, cette gestion intégrée est souvent suffisante. Mais en réalité, la plupart des applications de production atteignent un point où elles font des requêtes authentifiées en dehors du client Supabase : appels à leur propre API backend, fonctions edge accédées via fetch, ou intégrations tierces qui ont besoin du token d'accès passé en en-tête. À partir du moment où vous extrayez le token d'accès de la session et l'utilisez directement dans une couche HTTP séparée, vous êtes sorti de la protection de la sérialisation interne du client.
Un second mode de défaillance apparaît lorsque le client Supabase n'est pas correctement initialisé comme singleton. Si l'évaluation du module crée plusieurs instances de client — ce qui peut se produire dans certains contextes SSR ou dans des environnements de test où les modules sont réévalués — ces instances ne partagent pas leur état de rafraîchissement interne. Deux instances de client tenteront chacune indépendamment un rafraîchissement lorsque leurs requêtes respectives retourneront 401, et la condition de course réapparaît quelle que soit la sérialisation intégrée.
Le pattern singleton promise
La solution la plus élégante capture le rafraîchissement en vol comme une promesse et retourne cette même promesse à tous les appelants jusqu'à sa résolution. Au lieu que chaque appelant initie indépendamment un nouveau rafraîchissement, ils attendent tous une seule opération partagée. L'implémentation est compacte :
let refreshPromise: Promise<string> | null = null;
async function getValidAccessToken(): Promise<string> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (token && \!isTokenExpired(token)) {
return token;
}
if (\!refreshPromise) {
refreshPromise = supabase.auth
.refreshSession()
.then(({ data, error }) => {
if (error || \!data.session) {
throw error ?? new Error("Token refresh failed");
}
return data.session.access_token;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split(".")[1]));
// Tampon de 30 secondes pour éviter les tokens valides maintenant mais qui expirent avant que la requête arrive au serveur
return payload.exp * 1000 < Date.now() + 30_000;
} catch {
return true;
}
}
Le bloc finally est le détail saillant : il efface la référence de la promesse partagée après résolution ou rejet, de sorte que le prochain cycle de rafraîchissement démarre proprement. Sans lui, une promesse de rafraîchissement rejetée bloquerait définitivement les futures tentatives de rafraîchissement pendant toute la durée de vie du module. Le tampon de 30 secondes dans la vérification d'expiration adresse un cas limite subtil — un token qui passe la vérification isTokenExpired mais expire dans les quelques centaines de millisecondes suivantes avant que la requête atteigne le serveur retournera un 401, déclenchant inutilement un autre cycle de rafraîchissement.
L'approche de file d'attente d'intercepteurs
Pour les applications utilisant Axios, le pattern standard enveloppe la même logique de sérialisation dans un intercepteur de réponse. Cette approche gère automatiquement la nouvelle tentative — les requêtes échouées sont mises en file d'attente, le rafraîchissement se termine, et la file d'attente est vidée avec le nouveau token appliqué à chaque configuration de requête en attente avant qu'elles soient réessayées :
import axios, { type AxiosError, type AxiosRequestConfig } from "axios";
let isRefreshing = false;
let pendingQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];
function drainQueue(error: unknown, token: string | null): void {
pendingQueue.forEach(({ resolve, reject }) => {
if (error) reject(error);
else resolve(token\!);
});
pendingQueue = [];
}
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
});
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as AxiosRequestConfig & { _retry?: boolean };
if (error.response?.status \!== 401 || original._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
// Mettre cette requête en file d'attente — elle sera réessayée une fois le rafraîchissement en vol terminé
return new Promise<string>((resolve, reject) => {
pendingQueue.push({ resolve, reject });
}).then((token) => {
original.headers = { ...original.headers, Authorization: `Bearer ${token}` };
return api(original);
});
}
original._retry = true;
isRefreshing = true;
try {
const { data, error: refreshError } = await supabase.auth.refreshSession();
if (refreshError || \!data.session) throw refreshError ?? new Error("Refresh failed");
const newToken = data.session.access_token;
drainQueue(null, newToken);
original.headers = { ...original.headers, Authorization: `Bearer ${newToken}` };
return api(original);
} catch (err) {
drainQueue(err, null);
// Effacer la session Supabase locale pour que l'application atteigne un état non authentifié propre
await supabase.auth.signOut();
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
);
Le flag _retry sur la configuration de la requête originale prévient les boucles infinies — si la requête réessayée retourne également un 401 (par exemple parce que le nouveau token lui-même était invalide), l'intercepteur transmet l'erreur plutôt que de déclencher un autre rafraîchissement. Le signOut() explicite en cas d'échec du rafraîchissement garantit que l'état de session local de Supabase est effacé, de sorte que l'application atterrit dans un état non authentifié propre plutôt qu'en limbes où l'interface croit que l'utilisateur est connecté mais chaque requête échoue.
Garder le contexte React synchronisé
Les deux patterns ci-dessus gèrent le token au niveau HTTP, mais les applications React ont souvent besoin de la session actuelle disponible dans le contexte — pour le rendu conditionnel, pour passer les détails de l'utilisateur aux composants enfants, ou pour les appels non-Axios qui lisent le token directement. Le défi est que stocker le token dans l'état React et garder l'intercepteur synchronisé crée deux sources de vérité qui peuvent diverger sous les re-rendus.
Une approche plus propre garde le token canonique dans une ref à laquelle l'intercepteur peut accéder de manière synchrone, tandis que la souscription React gère séparément l'état de l'interface :
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const tokenRef = useRef<string | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
tokenRef.current = data.session?.access_token ?? null;
});
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, newSession) => {
setSession(newSession);
tokenRef.current = newSession?.access_token ?? null;
}
);
return () => subscription.unsubscribe();
}, []);
// Passer tokenRef à la configuration de votre intercepteur pour qu'il lise le token actuel
// de manière synchrone plutôt que de passer de manière asynchrone par l'état
useEffect(() => {
setupInterceptors(tokenRef);
}, []);
return <AuthContext.Provider value={{ session }}>{children}</AuthContext.Provider>;
}
Utiliser une ref plutôt que l'état signifie que l'intercepteur lit le token actuel de manière synchrone, sans déclencher de re-rendus et sans le problème de fermeture périmée qui survient lorsque les callbacks capturent une valeur d'état spécifique au moment de leur création. La souscription onAuthStateChange maintient à la fois la ref et l'état React cohérents à mesure que la session évolue.
Ce que Supabase gère — et ce qu'il ne gère pas
La frontière de protection de la sérialisation interne du client Supabase mérite d'être énoncée explicitement. Elle couvre les appels effectués via les propres méthodes du client : supabase.from(), supabase.rpc(), les opérations de stockage, les souscriptions realtime, et les méthodes auth elles-mêmes. Le client maintient une seule promesse de rafraîchissement en interne et met en file d'attente les appelants concurrents, de sorte que supabase.auth.getSession() appelé concurremment depuis plusieurs endroits recevra tous correctement la session rafraîchie une fois que le seul rafraîchissement en vol se termine.
Ce qui sort de cette protection : les appels fetch authentifiés construits manuellement avec le access_token de la session, tout client HTTP configuré indépendamment du client Supabase, et — comme noté plus haut — plusieurs instances du client Supabase. Si votre architecture extrait le token d'accès pour le passer à une couche API séparée, la frontière de protection contre les conditions de course se termine à ce point d'extraction. C'est là où les patterns ci-dessus deviennent nécessaires.
Comportement entre onglets
La condition de course a une variante qui apparaît entre les onglets du navigateur. Si un utilisateur a l'application ouverte dans deux onglets et que les deux détectent simultanément l'expiration du token, la gestion de session de chaque onglet tentera indépendamment un rafraîchissement. La plupart des implémentations auth gèrent cela via les événements localStorage — un onglet écrit les nouveaux tokens dans le stockage, l'autre onglet détecte l'événement storage et met à jour son état de session local plutôt que d'initier un rafraîchissement concurrent. L'écouteur onAuthStateChange de Supabase se déclenche en réponse à ces changements de stockage inter-onglets lorsque le client est initialisé avec detectSessionInUrl: true et que les événements de stockage ne sont pas supprimés.
Mais en réalité, cela ne fonctionne que si chaque onglet actif a le client Supabase correctement initialisé et souscrit aux changements d'état auth. Les applications qui initialisent le client paresseusement, ou qui mettent en cache la session au montage sans s'abonner aux changements continus via onAuthStateChange, manqueront les mises à jour inter-onglets. Le deuxième onglet tentera son propre rafraîchissement avec le refresh token déjà consommé, et l'utilisateur sera déconnecté dans cet onglet. La correction est la même : s'assurer que chaque onglet s'abonne à onAuthStateChange à l'initialisation et ne met pas en cache le token de session dans un état de niveau module qui persiste entre les navigations de routes.
Le reconnaître en production
Les conditions de course lors du rafraîchissement de token ne s'annoncent pas clairement. Elles apparaissent dans la surveillance des erreurs sous forme de 401 sporadiques sur des endpoints qui devraient être authentifiés, comme des terminaisons de session inattendues dans les analytiques, ou comme des rapports d'utilisateurs d'être déconnectés aléatoirement. Chaque défaillance individuelle ressemble à un problème d'authentification ponctuel plutôt qu'à un problème de concurrence systémique — ce qui est précisément pourquoi les équipes passent souvent du temps à investiguer la configuration de l'expiration des tokens, les paramètres des cookies ou les conditions réseau avant d'arriver à la vraie cause.
La condition préalable est spécifique : requêtes parallèles combinées à un événement d'expiration de token. Toute page qui charge des données depuis plusieurs sources simultanément est candidate. Les pages de tableau de bord, les vues de fil d'actualité, ou tout arbre de composants où plusieurs enfants appellent indépendamment useEffect pour récupérer leurs propres données créent le pattern de requêtes parallèles. Dans les applications React Query ou SWR, toute page où plusieurs requêtes sont montées simultanément produit les mêmes conditions. Une fois que vous savez quoi chercher, la correction est simple — mais elle nécessite d'aborder le problème comme un problème de concurrence plutôt qu'un problème de configuration d'authentification. La file d'attente d'intercepteurs et le pattern singleton promise sont tous deux bien établis. L'un ou l'autre fonctionne. L'invariant important est qu'au plus une opération de rafraîchissement peut être en vol à tout moment, et que chaque appelant attend son résultat plutôt que d'initier un rafraîchissement concurrent.
Insights connexes
- Structure et expiration des JWT — Les tokens d'accès et les refresh tokens servent des objectifs différents. Les tokens d'accès sont de courte durée et sans état ; les refresh tokens sont de longue durée, à usage unique, et généralement stockés côté serveur ou avec une politique de rotation. Comprendre la distinction informe sur l'agressivité des fenêtres d'expiration et ce qui se passe quand le rafraîchissement échoue.
- Row Level Security de Supabase et le token d'accès — Les politiques RLS évaluent les claims JWT de l'utilisateur demandant. Une condition de course qui cause une requête à procéder avec un token expiré, ou sans token, produira une erreur de permission refusée plutôt qu'un 401, ce qui peut rendre la cause sous-jacente plus difficile à identifier.
- React Query et la récupération parallèle — Les hooks
useQueryde React Query appelés depuis plusieurs composants sur la même page s'exécuteront tous concurremment par défaut. Configurer un client de requête partagé avec unequeryFnpersonnalisée qui appellegetValidAccessToken()garantit que toutes les requêtes passent par la même couche de sérialisation du rafraîchissement. - Service workers et mise en cache des tokens — Les applications qui utilisent des service workers pour mettre en cache les réponses API introduisent un troisième emplacement où l'état auth doit être maintenu cohérent. Un service worker qui met en cache des requêtes avec des en-têtes d'autorisation périmés peut servir des réponses obsolètes après un rafraîchissement de token, produisant des incohérences de données subtiles difficiles à attribuer à la gestion auth.