tva
← Analyses

Prévenir les conditions de concurrence lors du renouvellement des jetons dans les applications monopages

Les utilisateurs se font déconnecter sans explication. Le jeton de session semble valide dans localStorage, la dernière requête réussie s’est produite il y a quelques secondes, et les symptômes sont frustrants par leur irrégularité — sauf qu’ils se concentrent généralement 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 monopages, et une cause fréquente est une condition de concurrence lors du renouvellement des jetons.

Le mécanisme est simple une fois identifié. Les JWT expirent. Lorsqu’un jeton expire en cours de session, le comportement correct est d’échanger le jeton de renouvellement contre un nouveau jeton d’accès et de réessayer la requête initiale 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 jeton d’accès expire pendant cette rafale, toutes ces requêtes reçoivent un 401. Toutes détectent indépendamment le jeton expiré. Toutes tentent d’appeler le point de terminaison de renouvellement. Le premier appel réussit et l’ancien jeton de renouvellement est consommé. Le deuxième appel tente le même jeton de renouvellement — désormais invalidé — et échoue. L’utilisateur est déconnecté.

Pourquoi ce problème se manifeste rarement en développement

La condition de concurrence nécessite une coïncidence spécifique : plusieurs requêtes véritablement concurrentes, combinées à une expiration de jeton se produisant exactement au mauvais moment. Les environnements de développement ont généralement des durées de validité des jetons longues, 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 passe inaperçu en développement et se manifeste en production dans les conditions réelles d’utilisation — affectant le plus souvent les utilisateurs qui ont été actifs assez longtemps pour que leur jeton initial expire en cours de session.

Le reproduire délibérément nécessite de raccourcir les durées de vie des jetons et de naviguer vers des pages avec une récupération de données en parallèle. Avec Supabase, le paramètre jwt_expiry dans la configuration de votre projet contrôle la durée de vie du jeton d’accès. Mettez-le à soixante secondes, ouvrez une page de tableau de bord avec plusieurs requêtes, et attendez. La condition de concurrence apparaît généralement dans les premières minutes d’utilisation active, bien que le délai varie.

Comment ce problème se manifeste avec l’authentification Supabase

Le client @supabase/supabase-js v2 de Supabase gère le renouvellement des jetons en interne et inclut une logique de sérialisation — il maintient une seule promesse de renouvellement en cours 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 appelées via fetch, ou intégrations tierces qui ont besoin du jeton d’accès transmis dans un en-tête. À partir du moment où vous extrayez le jeton 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 renouvellement interne. Deux instances de client tenteront chacune indépendamment un renouvellement lorsque leurs requêtes respectives retourneront 401, et la condition de concurrence réapparaît quelle que soit la sérialisation intégrée.

Le schéma de la promesse unique partagée

La solution la plus élégante capture le renouvellement en cours 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 renouvellement, 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 point essentiel : il efface la référence de la promesse partagée après résolution ou rejet, de sorte que le prochain cycle de renouvellement démarre proprement. Sans lui, une promesse de renouvellement rejetée bloquerait définitivement les futures tentatives de renouvellement pendant toute la durée de vie du module. Le tampon de 30 secondes dans la vérification d’expiration traite un cas limite subtil — un jeton 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 renouvellement. Deux points à prendre en compte dans cette fonction utilitaire : les segments JWT sont encodés en base64url et atob n’accepte que le base64 standard ; convertissez donc - et _ en + et / avant le décodage, sinon certains jetons valides seront traités comme expirés à chaque appel. De plus, un jeton sans attribut exp est considéré comme non expiré lors de la comparaison ; rejetez donc explicitement tout jeton sans exp.

L’approche de file d’attente d’intercepteurs

Pour les applications utilisant Axios, le schéma 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 qui ont échoué sont mises en file d’attente, le renouvellement se termine, et la file d’attente est vidée avec le nouveau jeton 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;
    }
  }
);

L’indicateur _retry sur la requête qui a déclenché le renouvellement prévient les boucles infinies — si la requête réessayée retourne également un 401 (par exemple parce que le nouveau jeton lui-même était invalide), l’intercepteur transmet l’erreur plutôt que de déclencher un autre renouvellement. Dans cet exemple, les requêtes mises en file d’attente pendant un renouvellement ne reçoivent pas cet indicateur. Définissez également original._retry = true dans la branche de mise en file d’attente si une requête réessayée qui échoue à nouveau ne doit pas déclencher un autre renouvellement. Le signOut() explicite en cas d’échec du renouvellement garantit que l’état de session local de Supabase est effacé, de sorte que l’application retrouve un état non authentifié cohérent plutôt que dans un état intermédiaire où l’interface croit que l’utilisateur est connecté mais chaque requête échoue.

Garder le contexte React synchronisé

Les deux schémas ci-dessus gèrent le jeton au niveau HTTP, mais les applications React ont souvent besoin de la session actuelle disponible dans le contexte — pour le rendu conditionnel, pour transmettre les informations de l’utilisateur aux composants enfants, ou pour les appels non-Axios qui lisent le jeton directement. Le défi est que stocker le jeton dans l’état React et garder l’intercepteur synchronisé crée deux référentiels qui peuvent diverger lors des nouveaux rendus.

Une approche plus propre garde le jeton canonique dans une ref à laquelle l’intercepteur peut accéder de manière synchrone, tandis que l’abonnement 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 jeton actuel de manière synchrone, sans déclencher de nouveaux rendus et sans le problème de fermeture capturant un état obsolète qui survient lorsque les fonctions de rappel capturent une valeur d’état spécifique au moment de leur création. L’abonnement onAuthStateChange maintient à la fois la ref et l’état React cohérents à mesure que la session évolue. Le second effet ne prévoit aucun nettoyage dans cet exemple ; faites en sorte que setupInterceptors renvoie une fonction qui supprime l’intercepteur (avec interceptors.response.eject(id) pour Axios), puis renvoyez-la depuis l’effet, sinon React Strict Mode et les remontages l’enregistreront deux fois.

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 abonnements en temps réel, et les méthodes d’authentification elles-mêmes. Le client maintient une seule promesse de renouvellement en interne et met en file d’attente les appelants concurrents, de sorte que supabase.auth.getSession() appelé simultanément depuis plusieurs endroits permettra à tous les appelants de recevoir correctement la session renouvelée une fois que le seul renouvellement en cours 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 jeton d’accès pour le passer à une couche API séparée, la frontière de protection contre les conditions de concurrence se termine à ce point d’extraction. C’est là que les schémas ci-dessus deviennent nécessaires.

Comportement entre onglets

La condition de concurrence 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 jeton, la gestion de session de chaque onglet tentera indépendamment un renouvellement. La plupart des implémentations d’authentification gèrent cela via les événements localStorage — un onglet écrit les nouveaux jetons 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 renouvellement concurrent. Dans le navigateur, supabase-js v2 diffuse les événements d’authentification aux autres onglets qui partagent la même clé de stockage (via BroadcastChannel lorsque les sessions sont persistées), de sorte que onAuthStateChange s’y déclenche également. Cela n’a aucun lien avec detectSessionInUrl, qui détermine uniquement si le client lit les autorisations OAuth dans l’URL de la page.

Mais en réalité, cela ne fonctionne que si chaque onglet actif a le client Supabase correctement initialisé et abonné aux changements d’état d’authentification. Les applications qui initialisent le client à la demande, 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 renouvellement avec le jeton de renouvellement 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 jeton de session dans un état au niveau du module qui persiste entre les changements de route.

Le reconnaître en production

Les conditions de concurrence lors du renouvellement des jetons ne s’annoncent pas clairement. Elles apparaissent dans la surveillance des erreurs sous forme de 401 sporadiques sur des points de terminaison qui devraient être authentifiés, sous forme de fins de session inattendues dans les données d’analyse ou dans les signalements d’utilisateurs déconnectés de manière aléatoire. 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 à examiner la configuration de l’expiration des jetons, 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 jeton. 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 schéma 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 schéma de la promesse unique partagée sont tous deux bien établis. L’un ou l’autre fonctionne. L’invariant important est qu’au plus une opération de renouvellement peut être en cours à tout moment, et que chaque appelant attend son résultat plutôt que d’initier un renouvellement concurrent.

Articles connexes

  • Structure et expiration des JWT — Les jetons d’accès et les jetons de renouvellement remplissent des fonctions différentes. Les jetons d’accès sont de courte durée et sans état ; les jetons de renouvellement sont de longue durée, généralement à usage unique dans le cadre d’une politique de rotation, et stockés côté serveur. Supabase, par exemple, ne permet leur réutilisation que pendant un court intervalle (10 secondes par défaut) et révoque la session lorsqu’il détecte une réutilisation en dehors de cet intervalle. Comprendre la distinction permet de déterminer à quel point les délais d’expiration peuvent être courts et ce qui se passe quand le renouvellement échoue.
  • Sécurité au niveau des lignes de Supabase et le jeton d’accès — Les politiques RLS évaluent les attributs (claims) du JWT de l’utilisateur à l’origine de la requête. Supabase rejette un jeton expiré avec un code 401, mais une condition de concurrence qui amène une requête à être exécutée sans jeton la fait passer sous le rôle anon, pour lequel RLS renvoie généralement des résultats vides ou une erreur d’autorisation 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 useQuery de React Query appelés depuis plusieurs composants sur la même page s’exécuteront tous en parallèle par défaut. Configurer un client de requête partagé avec une queryFn personnalisée qui appelle getValidAccessToken() garantit que toutes les requêtes passent par la même couche de sérialisation du renouvellement.
  • Service workers et mise en cache des jetons — Les applications qui utilisent des service workers pour mettre en cache les réponses API introduisent un troisième emplacement où l’état d’authentification 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 renouvellement des jetons, produisant des incohérences de données subtiles difficiles à attribuer à la gestion de l’authentification.

Pour aller plus loin