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.