Volver a las notas

Refresh tokens en cookies HttpOnly, con rotación y detección de reutilización

11 de agosto de 2026·
jwtseguridadautenticacionnodejs

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

Un JWT de acceso que dura un día y vive en localStorage es una invitación: cualquier script que se ejecute en tu página (un XSS o una dependencia comprometida) puede leerlo y usarlo desde otro sitio hasta que caduque. El esquema que recomiendan las guías de seguridad es otro: access token corto y refresh token rotado en una cookie que JavaScript no puede leer.

Las dos piezas

Access token. Un JWT firmado con vida corta (unos 5-15 minutos) que el API comprueba en cada petición sin consultar la base de datos. Si alguien lo roba, solo le sirve unos minutos.

Refresh token. Un valor aleatorio largo y opaco (no hace falta que sea un JWT) que solo sirve para pedir un access token nuevo. Va en una cookie:

res.cookie('rt', refreshToken, {
  httpOnly: true,             // JavaScript no puede leerla
  secure: true,               // solo por HTTPS
  sameSite: 'strict',         // no se envía en peticiones desde otros sitios
  path: '/api/auth/refresh',  // solo viaja a este endpoint
  maxAge: 30 * 24 * 3600 * 1000,
});

path limita el riesgo: la cookie no acompaña a cada petición de tu API, solo a la de refresco. En el servidor guarda un hash del refresh token (como harías con una contraseña), no el valor en claro.

Rotación y detección de reutilización

Cada vez que se usa un refresh token, se invalida y se emite uno nuevo de la misma "familia". Si alguien presenta uno ya usado, significa que hay dos copias en circulación (la del usuario y la de un atacante). Como no sabes cuál es cuál, invalidas la familia entera y obligas a iniciar sesión de nuevo. Es lo que describe el RFC 9700 (las buenas prácticas de seguridad de OAuth 2.0) para clientes que no pueden guardar secretos.

// POST /api/auth/refresh
async function refresh(req, res) {
  const presented = req.cookies.rt;
  if (!presented) return res.status(401).end();

  const row = await db.findRefreshToken(sha256(presented));
  if (!row) return res.status(401).end();

  if (row.usedAt) {
    // Reutilización: alguien tiene una copia. Se corta toda la familia.
    await db.revokeFamily(row.familyId);
    res.clearCookie('rt', { path: '/api/auth/refresh' });
    return res.status(401).end();
  }
  if (row.expiresAt < new Date()) return res.status(401).end();

  const next = crypto.randomBytes(32).toString('base64url');
  await db.rotate(row.id, { familyId: row.familyId, hash: sha256(next) }); // marca usedAt y crea el nuevo

  res.cookie('rt', next, { /* mismas opciones que arriba */ });
  res.json({ accessToken: signAccessToken(row.userId) });
}

rotate tiene que ser atómico: si dos peticiones llegan a la vez con el mismo token, solo una puede ganar. Eso, y lo que pasa cuando dos pestañas refrescan a la vez, lo cuento en la nota sobre cierres de sesión inesperados.

Dónde va el access token

En memoria, en una variable del cliente, y se envía con Authorization: Bearer. Al recargar la página se pierde, y se pide uno nuevo con el refresh (la cookie viaja sola). Otra opción es meterlo también en una cookie HttpOnly. Entonces el navegador lo envía solo, y hay que protegerse de CSRF: SameSite, comprobar la cabecera Origin o un token anti-CSRF.

Lo que no arregla cifrar el JWT

Se lee a veces que hay que usar JWE (JWT cifrado) para que no te roben el token "por man-in-the-middle". No: contra quien escucha en la red te protege HTTPS. Un JWE cifrado se roba y se reutiliza exactamente igual que uno firmado, porque el servidor lo acepta tal cual. Cifrar sirve para otra cosa: que el cliente o un intermediario no puedan leer el contenido del token. Solo tiene sentido si metes datos que el portador no debe ver, y en ese caso es mejor no meterlos.

Al verificar el JWT, fija el algoritmo que aceptas (algorithms: ['ES256'] o el que uses) y comprueba exp, iss y aud. Aceptar el alg que venga en la cabecera es el origen de varias vulnerabilidades clásicas.

Fuentes