Revocar JWT antes de que expire: 5 estrategias que funcionan
Revocar JWT antes de que expire: denylist por jti en Redis, versión de token por usuario y refresh token rotation, con código TypeScript y tabla de riesgos.

Imagina que un usuario te escribe un domingo: le han robado el portátil con la sesión abierta y necesitas revocar su JWT ya. Entras al panel, pulsas "cerrar sesión en todos los dispositivos" y respiras.
El botón borra sus refresh tokens de la base de datos. Pero el access token que vive en el portátil robado sigue firmado, sigue sin caducar y tu API lo sigue aceptando. Durante 24 horas, porque así configuraste el expiresIn.
Ahí descubres que revocar JWT antes de que expiren no es una función que ya tengas. Es una decisión de arquitectura que no tomaste.
En corto: un JWT no se puede borrar: su firma es válida hasta exp y el servidor no guarda nada que eliminar. Para revocar JWT de verdad necesitas access tokens cortos (5-15 min) y un poco de estado donde importa: refresh tokens revocables con rotación, una denylist por jti o una versión de token por usuario. Si necesitas cortar cualquier petición al segundo, no quieres JWT: quieres sesiones o introspección.
¿Qué significa revocar un JWT?
Revocar un JWT es conseguir que el servidor rechace un token que todavía tiene una firma válida y un exp en el futuro. No se modifica el token, que ya está en manos del cliente. Lo que cambia es la regla que aplica el servidor al verificarlo.
Un JWT firmado (RFC 7519) es autocontenido: la API comprueba la firma con una clave y lee los claims sin preguntar a nadie. Esa es su gracia y también su problema. Si no preguntas a nadie, nadie te puede decir que ese token ya no vale.
Borrar el token del localStorage o la cookie solo lo quita de ese navegador. Por eso el "logout con JWT" que solo limpia el cliente no es un logout: es un olvido voluntario.
Por qué invalidar un JWT es difícil: la discusión que no se acaba
Invalidar un JWT es difícil porque verificarlo no consulta ningún estado: cualquier mecanismo de revocación obliga a reintroducir una consulta al servidor, que es justo lo que JWT prometía evitar.
El artículo "Stop using JWT for sessions" (copia archivada) de 2016 se ha enviado a Hacker News más de quince veces, entre sus dos partes. El hilo con más actividad suma 248 puntos y 245 comentarios (consultado en octubre de 2026 vía la API de HN Algolia).
En este comentario del hilo, un usuario resume el problema: invalidar obliga a que quien verifica vuelva a hablar con el backend de autenticación, justo lo que JWT prometía evitar. Su alternativa ni siquiera es JWT: tokens tradicionales cuya validez cachea 60 segundos en cada servidor de API, renovando la caché en segundo plano 30 segundos antes de que caduque.
Es exactamente el compromiso que vas a ver en todas las estrategias: cada segundo de revocación más rápida lo pagas con estado y con latencia. Mi opinión: para una app SaaS típica, la combinación ganadora es access token de 10 minutos + refresh token opaco con rotación, y denylist solo para los casos graves.
Estrategia 1: access tokens cortos y refresh token revocable
La forma más barata de revocar JWT es que duren tan poco que no haga falta. Un access token de 10 minutos limita la ventana de un token robado a 10 minutos. El refresh token, que dura días, es opaco y vive en tu base de datos: ese sí lo puedes borrar.
El código usa jose (API de la versión 6.x) para firmar y verificar:
import { SignJWT, jwtVerify } from 39;jose39;;
import { randomUUID } from 39;node:crypto39;;
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ ver: tokenVersion })
.setProtectedHeader({ alg: 39;HS25639; })
.setSubject(userId)
.setJti(randomUUID())
.setIssuedAt()
.setExpirationTime(39;10m39;)
.sign(secret);
}
El refresh token no debería ser un JWT. Es un string aleatorio (randomBytes(32).toString('base64url')) cuyo hash guardas en BD. Viaja en una cookie httpOnly, Secure y SameSite. Para el access token, la decisión es otra; la desmenucé en localStorage vs cookies.
Al hacer logout borras la fila del refresh token; el access token vive, como mucho, 10 minutos más. Es la forma de revocar JWT que no añade ni una consulta por petición.
Estrategia 2: JWT denylist por jti en Redis
Cuando 10 minutos no son aceptables (cuenta comprometida, empleado despedido), necesitas una lista de tokens prohibidos. El claim jti (RFC 7519, sección 4.1.7) es el identificador único del token, y es justo lo que metes en la denylist.
El truco está en el TTL: la entrada solo vive lo que le queda al token. Cuando caduca, la firma ya lo rechaza y Redis borra la clave. Sin cron jobs.
import Redis from 39;ioredis39;;
const redis = new Redis(process.env.REDIS_URL!);
export async function revokeAccessToken(jti: string, exp: number) {
const ttl = exp - Math.floor(Date.now() / 1000);
if (ttl > 0) await redis.set(`jwt:deny:${jti}`, 39;139;, 39;EX39;, ttl);
}
export async function isRevoked(jti: string) {
return (await redis.exists(`jwt:deny:${jti}`)) === 1;
}
Y el middleware de Express que lo comprueba después de verificar la firma:
import type { Request, Response, NextFunction } from 39;express39;;
import { jwtVerify } from 39;jose39;;
// `secret` es el mismo de signAccessToken
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.replace(/^Bearer /, 39;39;);
if (!token) return res.sendStatus(401);
try {
const { payload } = await jwtVerify(token, secret, { algorithms: [39;HS25639;] });
if (!payload.jti || (await isRevoked(payload.jti))) return res.sendStatus(401);
res.locals.auth = payload;
next();
} catch {
res.sendStatus(401);
}
}
Fíjate en algorithms: ['HS256']: fijar el algoritmo evita aceptar tokens firmados con otro distinto al que esperas. Y payload llega tipado como JWTPayload genérico; validar los claims propios (ver, roles) con un schema es donde Zod te ahorra un as peligroso.
Estrategia 3: versión de token para el logout de todos los dispositivos
La denylist sirve para revocar un JWT concreto, pero con el portátil robado no sabes qué jti tiene el ladrón. Quieres invalidar todos los tokens del usuario de golpe.
Para eso, cada usuario tiene un contador tokenVersion. Lo firmas dentro del token (el claim ver de la estrategia 1) y, al verificar, lo comparas con el valor actual. "Cerrar sesión en todos los dispositivos" es incrementar el contador.
export async function logoutEverywhere(userId: string) {
await redis.incr(`user:ver:${userId}`);
// además, borra todos sus refresh tokens en BD
}
export async function isCurrentVersion(userId: string, ver: unknown) {
// en producción: si la clave falta en Redis, lee el contador de BD y repuebla
const current = Number((await redis.get(`user:ver:${userId}`)) ?? 0);
return ver === current;
}
Y en requireAuth, justo después de la comprobación de la denylist, añade la línea que falta:
if (!payload.sub || !(await isCurrentVersion(payload.sub, payload.ver))) return res.sendStatus(401);
Sin esa línea, incrementar el contador no rechaza nada: los access tokens siguen vivos hasta su exp. Al firmar, tokenVersion sale de la misma clave: Number((await redis.get(`user:ver:${userId}`)) ?? 0).
Guarda el contador en la BD y usa Redis como caché: si Redis pierde la clave, el contador vuelve a 0 y los tokens viejos resucitan.
Una variante guarda un iat mínimo por usuario: se rechaza todo token emitido antes de esa fecha, sin añadir claims nuevos.
Estrategia 4: refresh token rotation con detección de reutilización
La rotación convierte el refresh token en uno de un solo uso: al canjearlo recibes uno nuevo y el anterior queda invalidado.
La parte que casi nadie implementa es la detección de reutilización. El RFC 9700 (OAuth 2.0 Security BCP, enero de 2025) lo describe en su sección 4.14.2: si se presenta un refresh token ya invalidado, el servidor sabe que hay una copia en manos ajenas y, como no puede saber quién es quién, "will revoke the active refresh token". En la práctica, quemas la familia entera.
import { createHash, randomBytes } from 39;node:crypto39;;
const sha256 = (t: string) => createHash(39;sha25639;).update(t).digest(39;hex39;);
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
// refreshTokens: tu repositorio (Postgres con Prisma, Drizzle, SQL a pelo...)
export async function rotateRefreshToken(presented: string) {
const stored = await refreshTokens.findByHash(sha256(presented));
if (!stored || stored.expiresAt < new Date()) throw new UnauthorizedError();
// markUsed debe ser atómico y fallar si el token ya se usó o su familia está revocada:
// UPDATE refresh_tokens SET used_at = now()
// WHERE id = $1 AND used_at IS NULL
// AND family_id NOT IN (SELECT id FROM token_families WHERE revoked_at IS NOT NULL)
const firstUse = await refreshTokens.markUsed(stored.id);
if (!firstUse) {
// reutilización detectada (o familia ya revocada)
await refreshTokens.revokeFamily(stored.familyId); // UPDATE token_families SET revoked_at = now()
throw new UnauthorizedError();
}
const next = randomBytes(32).toString(39;base64url39;);
await refreshTokens.create({
hash: sha256(next),
userId: stored.userId,
familyId: stored.familyId,
expiresAt: new Date(Date.now() + THIRTY_DAYS),
});
return next;
}
Cada login empieza una familia nueva (familyId). La revocación vive en la familia, no en cada token: así, un token creado mientras otra petición quemaba la familia nace ya muerto. Da igual quién use primero el token robado, el atacante o el usuario: el segundo en llegar dispara la detección y la familia muere. Ojo: si el ladrón tiene la única copia, como en el portátil robado, nadie más presenta el token y la rotación no detecta nada. Para eso está la versión por usuario.
Estrategia 5: tokens opacos e introspección (RFC 7662)
Si tu requisito es "revocar y que la siguiente petición falle, siempre", deja de pelearte con JWT. El RFC 7662 define la introspección: la API envía el token al servidor de autorización y recibe un JSON con active: true o false.
Es una consulta de red por petición, cacheable, como esos 60 segundos del comentarista de HN. Básicamente, una sesión con otro nombre.
Comparativa: cómo revocar JWT según lo que necesitas
La mejor estrategia depende de cuánto tiempo puedes tolerar un token robado: si son minutos, basta con tokens cortos; si tiene que ser inmediato, necesitas denylist, versión o introspección.
| Estrategia | Latencia de revocación | Coste por petición | Estado necesario | Limitación o riesgo |
|---|---|---|---|---|
| Access corto + refresh en BD | Hasta el exp del access (5-15 min) |
Ninguno extra | Fila por refresh token | La ventana del access token sigue abierta |
Denylist por jti |
Inmediata | 1 lectura de Redis | Una clave por token revocado, con TTL | Si Redis cae, eliges entre rechazar a todos o aceptar revocados |
| Versión de token por usuario | Inmediata | 1 lectura (Redis o BD) | Un contador por usuario | Es todo o nada: no revoca un dispositivo concreto |
| Refresh rotation con familias | En el siguiente refresh | Ninguno extra (solo en /refresh) |
Historial de tokens por familia | Pestañas que refrescan a la vez dan falsos positivos |
| Opaco + introspección (RFC 7662) | Inmediata (o el TTL de la caché) | 1 llamada de red al servidor de auth | Sesión completa en servidor | Acopla cada API al servidor de autorización |
Límites de estos enfoques: cuándo no usar JWT
Lo digo sin rodeos: si consultas Redis en cada petición para comprobar la denylist, has reinventado las sesiones, con un token más pesado y más piezas. Es justo la crítica del artículo de 2016. Si tu app es un monolito con un solo backend, una sesión en servidor con cookie es más simple y revoca de forma nativa; Better Auth te lo da hecho en TypeScript.
Segundo: con access tokens cortos y nada más, la revocación nunca es instantánea. Un token robado funciona hasta 15 minutos, algo difícil de defender en banca, salud o paneles de administración.
Tercero: en arquitecturas multi-servicio, el estado de revocación tiene que llegar a todos. Si cinco microservicios verifican el JWT por su cuenta, los cinco necesitan leer la denylist o la versión. Uno que se olvide es una puerta abierta.
Y cuarto: la rotación tiene una trampa operativa. Dos pestañas que refrescan a la vez presentan el mismo token y la segunda parece un robo. Necesitas un periodo de gracia corto o serializar el refresh en el cliente, y eso no viene en ninguna librería por defecto.
Un último apunte: todo lo que firmas en el token se congela hasta exp, permisos incluidos. Si le quitas a alguien el rol de admin, su access token lo seguirá diciendo hasta que caduque. El token dice bien quién eres; lo que puedes hacer cambia más a menudo, y por eso conviene separar autenticación y autorización: o consultas los permisos en el servidor en cada petición, o subes ver cada vez que cambian y el siguiente refresh trae los nuevos.
Qué hacer hoy con tus JWT
Abre tu configuración y busca el expiresIn de tus access tokens. Si pone 1d, 7d o no hay nada, ese es tu problema real, no la falta de denylist. Antes de pensar en cómo revocar JWT, bájalo a 10 minutos, saca el refresh token a una tabla con rotación y añade la versión por usuario para el botón de "cerrar sesión en todos los dispositivos".
Este tipo de decisión es la que conviene dejar escrita en la spec antes de pedirle a un agente que genere el módulo de auth. Si no, la IA elige por ti un JWT de 7 días. Así lo trabajamos en el curso Construye con IA, y la metodología completa está en el libro de Spec-Driven Development.
Preguntas frecuentes
¿Se puede revocar un JWT sin guardar nada en el servidor?
No de forma inmediata. Sin estado en el servidor, lo único que limita un token es su exp, así que la única "revocación" posible es esperar a que caduque. Por eso se combinan access tokens cortos con un refresh token que sí vive en la base de datos.
¿Cuánto debe durar un access token JWT?
Entre 5 y 15 minutos es el rango habitual en apps web: no satura el endpoint de refresh y un token robado sirve poco tiempo. Si necesitas menos de un minuto de exposición, usa sesiones o introspección.
¿Cómo hago logout con JWT correctamente?
Borra el refresh token de la base de datos, limpia la cookie en el cliente y, si el riesgo lo justifica, añade el jti del access token actual a la denylist con TTL hasta su exp. Limpiar solo el cliente no invalida el token.
¿Qué pasa con la denylist si Redis se cae?
Decídelo de antemano. Fallar cerrado rechaza toda petición autenticada mientras Redis no responda; fallar abierto acepta tokens quizá revocados. Un compromiso razonable: cerrado en operaciones sensibles, abierto en lecturas. El requireAuth de este post ya falla cerrado: si Redis lanza un error, el catch devuelve 401. Revisa también maxRetriesPerRequest y enableOfflineQueue de ioredis: por defecto reintenta y encola comandos mientras reconecta, y la petición se queda colgada en vez de fallar rápido.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Quieres construir con agentes en serio?
Curso «Construye con IA» con Claude Code, de la idea al producto.
Developer con más de 15 años en frontend y creador de Dominicode. Escribe sobre IA aplicada al desarrollo de software.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.