Volver a las notas

Cierres de sesión inesperados con refresh tokens rotados: la carrera de las pestañas

24 de septiembre de 2026·
jwtautenticacionfrontendconcurrencia

Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía errores y datos sin fuente.

Montas refresh tokens con rotación y detección de reutilización (como en la nota anterior) y empiezan los tickets: "me echa de la sesión cada cierto tiempo". No hay ningún ataque. Es una carrera entre peticiones del propio usuario.

Cómo pasa

  1. El access token caduca.
  2. La página lanza tres peticiones a la vez y las tres reciben un 401.
  3. Las tres llaman a /refresh con el mismo refresh token.
  4. La primera lo rota. Las otras dos presentan un token que ya está marcado como usado.
  5. El servidor lo interpreta como una reutilización, revoca la familia entera y el usuario queda fuera.

Con dos pestañas abiertas pasa lo mismo, aunque cada pestaña haga una sola petición.

Arreglo 1: una sola petición de refresco por pestaña

Todas las peticiones que reciben un 401 esperan a la misma promesa de refresco:

let refreshing = null;

function refreshOnce() {
  refreshing ??= fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
    .then((r) => (r.ok ? r.json() : Promise.reject(new Error('refresh failed'))))
    .then(({ accessToken }) => { setAccessToken(accessToken); return accessToken; })
    .finally(() => { refreshing = null; });
  return refreshing;
}

export async function apiFetch(url, opts = {}) {
  const call = () => fetch(url, { ...opts, headers: { ...opts.headers, Authorization: `Bearer ${getAccessToken()}` } });
  let res = await call();
  if (res.status === 401) {
    await refreshOnce();
    res = await call();
  }
  return res;
}

Así, tres 401 simultáneos producen un único /refresh, y las tres peticiones se repiten con el token nuevo.

Arreglo 2: coordinar las pestañas

Cada pestaña tiene su propia variable refreshing. Para que solo una refresque a la vez, la Web Locks API da un candado compartido entre pestañas del mismo origen:

async function refreshAcrossTabs() {
  return navigator.locks.request('auth-refresh', async () => {
    // Si otra pestaña ya refrescó mientras esperábamos el candado, la cookie nueva
    // ya está en el navegador y este refresh usará el token correcto.
    return refreshOnce();
  });
}

Como el refresh token vive en una cookie compartida por todas las pestañas, la segunda pestaña que entra en el candado ya envía el token rotado. Para repartir el access token nuevo sin otra llamada, BroadcastChannel permite avisar al resto de pestañas.

Arreglo 3: un margen corto en el servidor

Aun así habrá casos que se escapen: una petición que salió justo antes de la rotación, o una red móvil que reenvía. La práctica habitual es aceptar durante unos segundos el refresh token recién rotado y devolver el mismo resultado que en la primera llamada. La detección de reutilización solo salta fuera de ese margen:

const GRACE_MS = 10_000;

// Dentro del handler de /refresh, después de buscar el token presentado (row):
async function handleUsedToken(row, res) {
  const reciente = Date.now() - row.usedAt.getTime() < GRACE_MS;
  if (!reciente) {
    await db.revokeFamily(row.familyId);
    return res.status(401).end();
  }
  // Dentro del margen no se revoca la familia: se emite otro refresh token de la
  // misma familia, como si fuera la primera vez que se usa.
  const next = crypto.randomBytes(32).toString('base64url');
  await db.insertRefreshToken({ familyId: row.familyId, userId: row.userId, hash: sha256(next) });
  res.cookie('rt', next, { /* mismas opciones */ });
  return res.json({ accessToken: signAccessToken(row.userId) });
}

Es un compromiso: durante esos segundos, una copia robada también serviría. Mantenlo corto. Algunos proveedores de identidad lo exponen como opción configurable. Auth0, por ejemplo, lo llama reuse interval.

Cómo reproducirlo

En DevTools, pon un access token que caduque en 10 segundos, abre dos pestañas y lanza varias peticiones a la vez en cada una. Sin los arreglos, en pocos intentos verás la familia revocada. Con el arreglo 1 desaparece dentro de la pestaña, con el 2 entre pestañas, y el 3 cubre lo que queda.

Fuentes