tva
← Artículos

Prevenir condiciones de carrera al renovar tokens en aplicaciones de página única

Los usuarios pierden la sesión sin explicación. El token de sesión parece válido en localStorage, la última solicitud correcta ocurrió hace segundos y el patrón es desesperantemente inconsistente, salvo que suele concentrarse en cargas de página que activan varias llamadas simultáneas a la API. Es uno de los fallos de producción más desconcertantes de las aplicaciones de página única, y una causa frecuente es una condición de carrera al renovar tokens.

El mecanismo es sencillo una vez identificado. Los JWT vencen. Cuando un token vence durante una sesión, lo correcto es intercambiar el token de renovación por uno nuevo de acceso y reintentar la solicitud original de forma transparente. Pero las aplicaciones React rara vez hacen una sola solicitud a la vez. Un panel puede obtener simultáneamente los datos del usuario, el resumen de la cuenta, el número de notificaciones y la actividad reciente, todo al montar el componente y en paralelo. Si el token de acceso vence durante esa ráfaga, todas las solicitudes reciben un 401. Cada una detecta por su cuenta el token vencido y llama al endpoint de renovación. La primera llamada funciona y consume el token de renovación anterior. La segunda intenta usar ese mismo token, ahora invalidado, y falla. El usuario pierde la sesión.

Por qué rara vez aparece durante el desarrollo

La condición de carrera exige una coincidencia concreta: varias solicitudes realmente simultáneas y un token que vence justo en el peor momento. Los entornos de desarrollo suelen usar tokens de larga duración; la latencia de localhost es casi nula, por lo que las solicitudes terminan rápido y rara vez se superponen completamente; y la mayoría de los desarrolladores prueban una función a la vez en lugar de cargar páginas con muchos datos. Así, el fallo pasa inadvertido durante el desarrollo y aparece en producción con patrones de uso reales, sobre todo entre usuarios que llevan suficiente tiempo activos para que su token inicial venza durante la sesión.

Para reproducirlo deliberadamente, hay que acortar la duración de los tokens y navegar a páginas que obtengan datos en paralelo. En Supabase, el ajuste jwt_expiry de la configuración del proyecto controla la duración del token de acceso. Fíjelo en sesenta segundos, abra un panel con varias consultas y espere. La condición de carrera suele aparecer en los primeros minutos de uso activo, aunque el tiempo varía.

Cómo se manifiesta con la autenticación de Supabase

El cliente @supabase/supabase-js v2 gestiona internamente la renovación e incluye lógica de serialización: mantiene una única promesa de renovación en curso y hace que las llamadas simultáneas esperen su resultado. Para aplicaciones que usan exclusivamente el cliente Supabase en todas sus llamadas a la API, esta gestión integrada suele bastar. En la práctica, la mayoría de las aplicaciones en producción acaban haciendo solicitudes autenticadas fuera del cliente: llamadas a su propio backend, funciones en el borde accedidas mediante fetch o integraciones de terceros que necesitan el token de acceso en un encabezado. En cuanto extrae el token de la sesión y lo utiliza directamente en una capa HTTP independiente, deja de estar protegido por la serialización interna del cliente.

Otro tipo de fallo aparece cuando el cliente Supabase no se inicializa correctamente como instancia única. Si la evaluación de módulos crea varias instancias —algo que puede ocurrir en determinados contextos SSR o en pruebas donde se vuelven a evaluar los módulos—, estas no comparten su estado interno de renovación. Dos instancias intentarán renovar de forma independiente cuando sus respectivas solicitudes reciban un 401, y la condición de carrera reaparecerá pese a la serialización integrada.

El patrón de promesa única compartida

La solución más elegante conserva la renovación en curso como una promesa y devuelve esa misma promesa a todas las llamadas hasta que se resuelve. En lugar de iniciar una renovación por separado, todas esperan una única operación compartida. La implementación es compacta:

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]));
    // 30-second buffer avoids tokens that are valid now but expire before the request completes
    return payload.exp * 1000 < Date.now() + 30_000;
  } catch {
    return true;
  }
}

El bloque finally es el detalle clave: elimina la referencia a la promesa compartida tras resolverse o rechazarse, para que el siguiente ciclo de renovación empiece sin estado pendiente. Sin él, una promesa rechazada bloquearía permanentemente los futuros intentos durante toda la vida del módulo. El margen de 30 segundos en la comprobación de vencimiento resuelve un caso sutil: un token que supera isTokenExpired pero vence unos cientos de milisegundos después, antes de llegar al servidor, devolverá un 401 y provocará otro ciclo de renovación innecesario. La función auxiliar tiene dos salvedades: los segmentos JWT usan base64url y atob solo acepta base64 estándar, así que convierta - y _ en + y / antes de decodificar; de lo contrario, algunos tokens válidos se tratarán como vencidos en cada llamada. Además, un token sin la afirmación exp se considera no vencido en la comparación, por lo que debe rechazar explícitamente los tokens sin exp.

El enfoque de cola en el interceptor

En aplicaciones con Axios, el patrón habitual incorpora la misma lógica de serialización en un interceptor de respuestas. Este enfoque gestiona automáticamente los reintentos: las solicitudes fallidas se ponen en cola, se completa la renovación y se procesa la cola aplicando el nuevo token a la configuración de cada solicitud pendiente antes de reintentarlas:

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) {
      // Queue this request — it will be retried once the in-flight refresh completes
      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);
      // Clear the local Supabase session so the app reaches a clean unauthenticated state
      await supabase.auth.signOut();
      return Promise.reject(err);
    } finally {
      isRefreshing = false;
    }
  }
);

El indicador _retry de la solicitud que inició la renovación evita los bucles infinitos: si la solicitud reintentada también devuelve un 401 —por ejemplo, porque el nuevo token es inválido—, el interceptor propaga el error en lugar de volver a renovar. En este ejemplo, las solicitudes que se ponen en cola mientras hay una renovación en curso no reciben el indicador. Establezca original._retry = true también en la rama de la cola si una solicitud reintentada que vuelve a fallar no debe iniciar otra renovación. La llamada explícita a signOut() cuando falla la renovación garantiza que se borre el estado local de sesión de Supabase. Así, la aplicación queda en un estado claro sin autenticación, en vez de mantener una interfaz que cree que el usuario sigue conectado mientras todas las solicitudes fallan.

Mantener React Context sincronizado

Los dos patrones anteriores gestionan el token en la capa HTTP, pero las aplicaciones React suelen necesitar la sesión actual en el contexto: para renderizar según la autenticación, pasar datos del usuario a componentes hijos o realizar llamadas ajenas a Axios que leen el token directamente. El problema es que guardar el token en el estado de React y sincronizarlo con el interceptor crea dos fuentes de referencia que pueden divergir al volver a renderizar.

Un enfoque más limpio mantiene el token de referencia en una ref a la que el interceptor puede acceder de forma síncrona, mientras la suscripción de React gestiona por separado el estado de la interfaz:

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();
  }, []);

  // Pass tokenRef to your interceptor setup so it reads the current token
  // synchronously rather than going async through state
  useEffect(() => {
    setupInterceptors(tokenRef);
  }, []);

  return <AuthContext.Provider value={{ session }}>{children}</AuthContext.Provider>;
}

Usar una ref en lugar del estado permite al interceptor leer el token actual de forma síncrona, sin provocar nuevos renderizados y sin el problema de cierres con valores desactualizados que surge cuando las funciones capturan un valor de estado al crearse. La suscripción onAuthStateChange mantiene tanto la ref como el estado de React coherentes a medida que evoluciona la sesión. El segundo efecto no incluye limpieza en el ejemplo. Haga que setupInterceptors devuelva una función que elimine el interceptor (en Axios, interceptors.response.eject(id)) y devuélvala desde el efecto; de lo contrario, React Strict Mode y los remontajes lo registrarán dos veces.

Qué gestiona Supabase y qué queda fuera

Conviene precisar el alcance de la serialización interna del cliente Supabase. Cubre las llamadas realizadas mediante sus propios métodos: supabase.from(), supabase.rpc(), operaciones de almacenamiento, suscripciones en tiempo real y los propios métodos de autenticación. El cliente mantiene internamente una única promesa de renovación y hace esperar a las llamadas simultáneas, de modo que varias llamadas concurrentes a supabase.auth.getSession() recibirán correctamente la sesión renovada cuando termine la única renovación en curso.

Quedan fuera de esta protección las llamadas autenticadas con fetch construidas manualmente con el access_token de la sesión, cualquier cliente HTTP configurado por separado y, como ya se indicó, las múltiples instancias del cliente Supabase. Si su arquitectura extrae el token para pasarlo a otra capa de API, la protección contra condiciones de carrera termina en ese punto. Ahí se hacen necesarios los patrones anteriores.

Comportamiento entre pestañas

La condición de carrera tiene una variante entre pestañas del navegador. Si un usuario tiene la aplicación abierta en dos pestañas y ambas detectan simultáneamente el vencimiento del token, la gestión de sesión de cada una intentará renovarlo de forma independiente. La mayoría de las implementaciones de autenticación lo gestionan mediante eventos de localStorage: una pestaña escribe los nuevos tokens, la otra detecta el evento storage y actualiza su sesión local en lugar de iniciar una renovación competidora. En el navegador, supabase-js v2 difunde los eventos de autenticación a otras pestañas que comparten la misma clave de almacenamiento (mediante BroadcastChannel cuando las sesiones se guardan de forma persistente), por lo que onAuthStateChange también se activa allí. Esto no depende de detectSessionInUrl, que solo controla si el cliente lee las concesiones OAuth de la URL de la página.

En la práctica, esto solo funciona si todas las pestañas activas tienen el cliente Supabase correctamente inicializado y están suscritas a los cambios de autenticación. Las aplicaciones que inicializan el cliente bajo demanda o guardan la sesión al montar el componente sin suscribirse a cambios posteriores mediante onAuthStateChange no recibirán las actualizaciones entre pestañas. La segunda intentará renovar con el token ya consumido y el usuario perderá allí la sesión. La solución es la misma: asegúrese de que cada pestaña se suscriba a onAuthStateChange al inicializarse y no conserve el token de sesión en un estado de módulo que persista entre cambios de ruta.

Cómo reconocerlo en producción

Las condiciones de carrera al renovar tokens no se identifican claramente. Aparecen en el monitoreo como errores 401 esporádicos en endpoints que deberían estar autenticados, como cierres inesperados de sesión en la analítica o como usuarios que indican que pierden la sesión al azar. Cada fallo parece un problema aislado de autenticación, en vez de uno sistemático de concurrencia. Por eso los equipos suelen investigar primero la duración de los tokens, la configuración de cookies o las condiciones de red antes de encontrar la causa real.

La condición previa es concreta: solicitudes paralelas junto con el vencimiento de un token. Cualquier página que cargue datos de varias fuentes simultáneamente puede desencadenarla. Los paneles, las vistas de actividad y los árboles de componentes cuyos hijos llaman por separado a useEffect para obtener sus datos generan este patrón. En aplicaciones React Query o SWR, cualquier página donde se monten varias consultas simultáneamente produce las mismas condiciones. Una vez identificado el patrón, la solución es sencilla, pero requiere tratarlo como un problema de concurrencia, no de configuración de autenticación. Tanto la cola del interceptor como la promesa única compartida son patrones consolidados. Ambos funcionan. La condición que debe mantenerse es que haya como máximo una renovación en curso y que todas las llamadas esperen su resultado en lugar de iniciar otra renovación competidora.

Artículos relacionados

  • Estructura y vencimiento de JWT — Los tokens de acceso y de renovación tienen funciones distintas. Los de acceso son de corta duración y sin estado; los de renovación duran más, suelen ser de un solo uso bajo una política de rotación y se almacenan en el servidor. Supabase, por ejemplo, permite reutilizarlos solo dentro de un intervalo breve (10 segundos de forma predeterminada) y revoca la sesión si detecta una reutilización fuera de ese intervalo. Entender la diferencia ayuda a fijar los plazos de vencimiento y a prever qué ocurre si falla la renovación.
  • Seguridad a nivel de fila de Supabase y token de acceso — Las políticas RLS evalúan las afirmaciones del JWT del usuario que realiza la solicitud. Supabase rechaza un token vencido con un 401, pero una condición de carrera que permite enviar una solicitud sin token hace que se ejecute con el rol anon, para el cual RLS suele devolver resultados vacíos o un error de permisos en lugar de un 401, lo que puede dificultar la identificación de la causa.
  • React Query y obtención de datos en paralelo — Los hooks useQuery de React Query llamados desde varios componentes de una misma página se ejecutan simultáneamente por defecto. Configurar un cliente de consultas compartido con una queryFn personalizada que llame a getValidAccessToken() garantiza que todas las consultas pasen por la misma capa de serialización de renovaciones.
  • Service workers y caché de tokens — Las aplicaciones que usan service workers para almacenar respuestas de API en caché incorporan un tercer lugar donde debe mantenerse coherente el estado de autenticación. Un service worker que guarda solicitudes con encabezados de autorización desactualizados puede servir respuestas antiguas tras renovar un token, lo que genera incoherencias sutiles en los datos difíciles de atribuir a la gestión de autenticación.

Artículos relacionados