Cómo solucionar las condiciones de carrera en la renovación de tokens con la Web Locks API
Corrige las carreras de refresh de tokens entre pestañas con Web Locks API, navigator.locks.request y una revalidación del token.
Cuando varias pestañas comparten un mismo refresh token rotativo, la llamada de renovación de la primera pestaña puede invalidar el token que las demás pestañas están a punto de enviar. Sus renovaciones fallan entonces y el usuario puede terminar con la sesión cerrada en todas las pestañas a la vez. La solución consiste en envolver la renovación en navigator.locks.request para que exactamente una pestaña la ejecute mientras el resto espera, y luego reutilizar el token que esta almacenó.
Si estás persiguiendo un reporte de error que dice «se me cerró la sesión sin hacer nada» y nunca se reproduce en tu máquina, pregunta cuántas pestañas tenía abiertas quien lo reportó. Una cola de renovación de una sola pestaña en tu interceptor es correcta hasta donde llega, pero no puede detectar esta condición de carrera.
Este artículo recorre la secuencia que produce el cierre de sesión, por qué un flag en localStorage no es una solución fiable y las tres piezas de la solución: el lock, la reverificación dentro de él y la integración con el interceptor.
Puntos clave
- Cuando varias pestañas renuevan simultáneamente un token rotado, solo la primera llamada tiene éxito; el servidor invalida el token que todas las demás pestañas están a punto de enviar, cerrando la sesión del usuario en todas partes.
navigator.locks.requestproporciona a cada pestaña, iframe y worker del mismo origen un mutex compartido, y es Baseline Widely available, por lo que no se necesita código de fallback.- Un flag en
localStorageno es un lock: no existe un compare-and-set atómico, y un flag escrito por una pestaña que se cerró de forma abrupta queda bloqueado, mientras que un Web Lock se libera automáticamente. - Las pestañas en espera deben reverificar el token almacenado dentro del callback del lock y condicionar la decisión a la propia expiración del token, no a una marca de tiempo de «renovado recientemente».
- Hecho correctamente, cualquier número de pestañas que se encuentre con un token expirado produce exactamente una renovación de red.
¿Por qué falla un refresh token entre múltiples pestañas?
La condición de carrera necesita cuatro ingredientes: access tokens de vida corta, un endpoint de renovación, rotación de refresh tokens y más de una pestaña. Los cuatro son comunes. RFC 9700, el OAuth 2.0 Security Best Current Practice, deja a los clientes públicos dos opciones para los refresh tokens: vincular cada uno al cliente al que fue emitido, o entregar uno nuevo en cada uso. Eso convierte la rotación en la postura por defecto para las SPA.
La secuencia: el usuario tiene tres pestañas abiertas y el access token expira. La siguiente petición de cada pestaña recibe un 401, y el interceptor de cada pestaña llama de forma independiente al endpoint de renovación. La llamada de la pestaña A llega primero y tiene éxito; si el servidor rota e invalida el refresh token anterior al usarlo, las pestañas B y C están ahora enviando una credencial muerta. Sus renovaciones fallan, sus manejadores de error tratan una renovación fallida como un fallo de autenticación terminal, y el usuario es redirigido al login en medio de su tarea.
En un session replay este bug tiene una firma distintiva: una redirección a la pantalla de login sin ninguna interacción del usuario previa, que ocurre en todas y cada una de las pestañas abiertas del usuario dentro del mismo segundo. Esa firma, y no algo en la consola, es lo que lo identifica.
¿Por qué el estado por pestaña o un flag en localStorage no lo resuelven?
El flag isRefreshing de tu interceptor y su cola de promesas viven en la memoria JavaScript de una sola pestaña. Las pestañas no comparten memoria, así que la pestaña B nunca ve el flag de la pestaña A. La coordinación entre pestañas necesita una primitiva a nivel de navegador.
El workaround tradicional, un flag de «renovación en curso» en localStorage, no es un lock. La Web Storage API te da getItem y setItem pero ningún compare-and-set atómico, por lo que dos pestañas pueden leer ambas que no hay renovación en curso y ambas iniciar una antes de que cualquiera de las escrituras se materialice. El flag también falla en la dirección opuesta: si la pestaña que lo estableció se cierra abruptamente o a mitad de la renovación, el flag queda establecido para siempre y todas las pestañas supervivientes esperan una renovación que nunca terminará. Un Web Lock es liberado por el navegador en el momento en que el documento de su titular desaparece, que es exactamente el fallo que atasca un flag artesanal.
¿Cómo envolver la renovación en navigator.locks.request?
La Web Locks API proporciona a cada pestaña, iframe y worker de un origen un mutex compartido. Con navigator.locks.request(name, callback), solo un titular de un nombre dado ejecuta su callback en un momento determinado, y el navegador libera el lock tan pronto como la promesa que devuelve ese callback se resuelve, ya sea con éxito o con rechazo. No hay ninguna llamada de unlock que se pueda olvidar ni ningún lock filtrado ante un fetch fallido. Todos los motores principales han implementado la API desde que Safari 15.4 la añadió en marzo de 2022, razón por la cual MDN la califica como Baseline Widely available, así que no se justifica ninguna detección de características ni rama de fallback.
async function refreshTokenAcrossTabs() {
return navigator.locks.request("token-refresh", async () => {
const existing = getUsableToken();
if (existing) return existing; // another tab already refreshed
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error("refresh_failed");
const { accessToken, expiresAt } = await res.json();
writeToken(accessToken, expiresAt);
return accessToken;
});
}
readToken y writeToken son deliberadamente abstractos: dónde viven los tokens es una decisión de seguridad aparte que este artículo no toma, y el lock funciona igual independientemente de ello.
La reverificación: comprueba el token, no el reloj
El paso que la mayoría de las implementaciones se salta es la reverificación dentro del callback del lock: una pestaña que esperó por el lock debería comprobar primero si el token almacenado es ahora válido y, si lo es, devolverlo sin una segunda llamada de red. Tres pestañas se encolan en el lock; la primera hace el ida y vuelta, la segunda y la tercera lo adquieren después, encuentran un token utilizable y retornan de inmediato. Una sola renovación de red, independientemente del número de pestañas.
Condiciona esa reverificación a si el token almacenado es realmente utilizable, no a cuán reciente fue la escritura de una marca de tiempo. Un criterio basado en el reloj de pared del tipo «renovado en los últimos 5 segundos» falla de dos maneras: una renovación más lenta que la ventana hace que las pestañas en espera concluyan erróneamente que no ocurrió nada y disparen duplicados, y un reloj de cliente desfasado invalida la comparación en cualquiera de las dos direcciones. La propia expiración del token no puede mentir sobre sí misma.
const SKEW_MS = 30_000; // tolerate modest clock drift
function getUsableToken() {
const stored = readToken(); // { token, expiresAt } or null
if (!stored) return null;
return stored.expiresAt - SKEW_MS > Date.now() ? stored.token : null;
}
Integrando el lock en un interceptor de 401
El trabajo del interceptor no cambia: capturar el 401, obtener un token nuevo, reintentar la petición original una vez. La única diferencia es que la llamada de renovación es ahora refreshTokenAcrossTabs(), de modo que la serialización abarca todas las pestañas.
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
if (original.url?.includes("/auth/refresh")) {
await logout(); // the refresh itself failed: session is gone
return Promise.reject(error);
}
original._retry = true;
try {
const token = await refreshTokenAcrossTabs();
original.headers.Authorization = `Bearer ${token}`;
return api(original);
} catch (refreshError) {
await logout();
return Promise.reject(refreshError);
}
}
);
La guarda _retry previene bucles, y un 401 proveniente del propio endpoint de renovación significa cierre de sesión, no reintento. Desde la perspectiva de una pestaña en espera, el recorrido completo es: 401, encolarse en el lock, adquirirlo, encontrar un token válido, retornar con cero llamadas de red, reintentar la petición original.
Casos límite que conviene conocer
- Web Locks requiere un contexto seguro;
http://localhostcalifica como un origen potencialmente confiable. - Las reglas de terminación de la especificación liberan los locks de un documento al descargarse, por lo que un lock nunca sobrevive a una recarga o navegación y nunca constituye estado durable.
- Mantén la sección crítica limitada únicamente a la renovación; cualquier otra cosa que esperes con
awaitdentro de ella bloquea todas las pestañas. - Nunca solicites el mismo lock dentro de su propio callback: la solicitud interna se encola detrás de la retención externa y se cuelga silenciosamente, para siempre.
- El modo compartido, la elección de líder con
ifAvailableystealexisten para otros trabajos; la renovación de tokens no necesita ninguno de ellos. - BroadcastChannel informa a las demás pestañas de que algo ocurrió; un lock impide que todas lo hagan a la vez.
Conclusión
El bug del cierre de sesión aleatorio es una condición de carrera de sistemas distribuidos ejecutándose en la máquina del usuario, y el navegador incluye el mutex que le pone fin. Envuelve tu renovación en navigator.locks.request, haz que la primera línea del callback sea una comprobación de validez del token, y encamina tu manejador de 401 a través de él. Reproduce primero el bug con varias pestañas y un tiempo de vida de token corto, luego aplica el lock y observa cómo N llamadas de renovación se reducen a una.
Preguntas frecuentes
¿Puede BroadcastChannel reemplazar a la Web Locks API para la renovación de tokens entre pestañas?
No. BroadcastChannel es un transporte de mensajería, no un mutex: puede anunciar que ocurrió una renovación, pero nada impide que dos pestañas inicien ambas una antes de que llegue cualquiera de los mensajes, la misma condición de carrera de leer-y-actuar que tiene un flag en localStorage. Usa navigator.locks.request para serializar la renovación, y añade BroadcastChannel después solo si quieres enviar el nuevo token a las pestañas que estén escuchando.
¿Cómo añado un timeout a una llamada a navigator.locks.request?
Pasa un AbortSignal mediante la opción signal. Abórtalo mientras la solicitud sigue en la cola y la promesa se rechazará con un AbortError, de modo que AbortSignal.timeout te da un plazo límite para la espera. Una vez concedido el lock, la señal deja de tener efecto alguno, así que un timeout no puede interrumpir un callback que ya se está ejecutando. Combinar signal con steal o ifAvailable provoca un rechazo con NotSupportedError, así que elige una sola estrategia por solicitud.
¿Funciona la Web Locks API en web workers y service workers?
Sí. La especificación expone LockManager tanto a contextos Window como Worker, por lo que los dedicated workers, shared workers y service workers pueden llamar a navigator.locks.request. Todos los contextos del mismo origen comparten un único gestor de locks, lo que significa que un worker que solicite el lock 'token-refresh' se encola junto con las pestañas que solicitan el mismo nombre. Por lo tanto, el patrón sigue siendo correcto incluso si parte de tu lógica de autenticación se ejecuta fuera del hilo principal.