* notes / jwt

JWT, permisos y Redis: cómo gestionar revocación a escala

Una arquitectura híbrida que conserva la apatridia del JWT sin renunciar a revocar sesiones en milisegundos: denylist en Redis, caché de permisos por usuario y refresh tokens con rotación.

January 25, 20257 min read#jwt#redis#auth#seguridad

Recuerdo con nitidez el día en que un compañero me preguntó, con cara de quien acaba de descubrir un truco de magia inquietante: "¿por qué después de cerrar sesión sigo pudiendo llamar al endpoint con el mismo token durante una hora?". La respuesta (que un JWT firmado es, por definición, válido hasta que expira, sin que el servidor tenga modo trivial de invalidarlo) le pareció directamente un bug de diseño. Y, en cierto sentido, lo es. La promesa de la autenticación stateless tiene letra pequeña, y ese día decidí escribir la arquitectura híbrida que llevo usando desde entonces: JWT para autenticar, Redis para revocar y cachear permisos.

Contexto: la paradoja de los tokens autosuficientes

Un JSON Web Token es, en esencia, tres bloques codificados en Base64URL separados por puntos: cabecera, payload y firma. La cabecera declara el algoritmo (HS256, RS256, ES256), el payload contiene las claims (sub, exp, iat, jti y las que añadas tú), y la firma se calcula sobre los dos primeros bloques con la clave secreta o privada. El servidor, al recibir el token, recomputa la firma y la compara; si coinciden, el token es válido. Punto.

La gran virtud de este esquema (que cualquier instancia puede validar sin consultar una base de datos) es, simultáneamente, su gran debilidad. No hay un campo active = false que un administrador pueda alternar. Si necesitas expulsar a un usuario antes de que su token expire (logout explícito, contraseña comprometida, baja inmediata por incumplimiento), tienes tres opciones realistas:

  1. Reducir drásticamente el exp (por ejemplo, cinco minutos) y emitir refresh tokens. Mitigación parcial: el atacante dispondrá, en el peor caso, de cinco minutos.
  2. Mantener una lista de versionado de claves por usuario (tokenVersion), de modo que cualquier modificación incremente el contador y los tokens previos queden invalidados al ser verificados. Implica leer ese contador en cada petición.
  3. Mantener una denylist de identificadores de token (jti) en un almacén rápido, normalmente Redis, con TTL igual a la vida restante del token.

Yo combino la primera con la tercera. El exp se mantiene corto (quince minutos para el access token, siete días para el refresh), y la denylist resuelve los casos de revocación inmediata. Asimismo, aprovecho Redis para cachear el conjunto de permisos del usuario y evitar golpear la base de datos en cada petición autenticada.

ClienteAPIRedisPostgresGET /orders Bearer eyJ...EXISTS denylist:jti:abc0 (no revocado)GET perms:user:42MISSSELECT permissions...["orders.read","orders.write"]200 OK + payload

Implementación: firma, verificación y middleware

Mi stack actual es Node 22 con TypeScript, jose para JWT y ioredis como cliente. Prefiero jose sobre jsonwebtoken por dos razones: soporta EdDSA y ES256 con menos fricción, y su API basada en promesas encaja mejor en código moderno. Para entornos multi-servicio, donde quiero que un servicio firme y otros solo verifiquen, recurro a RS256: la clave privada vive únicamente en el servicio emisor, y los consumidores reciben la pública mediante un endpoint JWKS. Para monolitos sin esa segregación, HS256 con una clave robusta basta y sobra.

import { SignJWT, jwtVerify } from "jose";
import { randomUUID } from "node:crypto";
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
 
export async function issueAccessToken(userId: string, perms: string[]) {
  const jti = randomUUID();
  const token = await new SignJWT({ perms })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(userId)
    .setJti(jti)
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(secret);
  return { token, jti };
}
 
export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ["HS256"],
    clockTolerance: 5,
  });
  return payload as {
    sub: string;
    jti: string;
    exp: number;
    perms: string[];
  };
}

El middleware de Express/Fastify queda así de compacto:

import type { FastifyRequest, FastifyReply } from "fastify";
import { redis } from "./redis";
import { verifyAccessToken } from "./jwt";
 
export async function authGuard(req: FastifyRequest, reply: FastifyReply) {
  const header = req.headers.authorization;
  if (!header?.startsWith("Bearer ")) {
    return reply.code(401).send({ error: "missing_token" });
  }
 
  const token = header.slice(7);
  let payload: Awaited<ReturnType<typeof verifyAccessToken>>;
  try {
    payload = await verifyAccessToken(token);
  } catch {
    return reply.code(401).send({ error: "invalid_token" });
  }
 
  const revoked = await redis.exists(`denylist:jti:${payload.jti}`);
  if (revoked) {
    return reply.code(401).send({ error: "revoked_token" });
  }
 
  const cacheKey = `perms:user:${payload.sub}`;
  let perms = await redis.smembers(cacheKey);
  if (perms.length === 0) {
    perms = await loadPermsFromDb(payload.sub);
    if (perms.length > 0) {
      await redis.sadd(cacheKey, ...perms);
      await redis.expire(cacheKey, 300);
    }
  }
 
  req.auth = { userId: payload.sub, perms };
}

La revocación, por su parte, es deliberadamente trivial: cuando un usuario cierra sesión o un administrador lo expulsa, persistimos el jti con un TTL igual al tiempo restante hasta exp. Una vez expira el TTL, la entrada desaparece sola y Redis no acumula basura indefinidamente.

export async function revokeAccess(jti: string, exp: number) {
  const ttl = Math.max(1, exp - Math.floor(Date.now() / 1000));
  await redis.set(`denylist:jti:${jti}`, "1", "EX", ttl);
}
 
export async function invalidatePermsCache(userId: string) {
  await redis.del(`perms:user:${userId}`);
}

Para el refresh, mantengo un esquema de rotación obligatoria: cada vez que el cliente intercambia un refresh token, emito uno nuevo y marco el anterior como consumido en Redis (refresh:consumed:<jti>). Si llega un intento de reutilizar un refresh ya consumido, síntoma clásico de robo, invalido la familia entera de tokens del usuario. Es la recomendación de la OWASP para sesiones deslizantes, y vale cada línea que cuesta implementarla.

Hallazgos: dónde se rompe la teoría

He aprendido tres lecciones, ninguna obvia desde la pizarra inicial.

La primera es que la denylist no escala si no acotas el dominio. Si tu sistema emite cien mil tokens al día y mantienes los TTL en quince minutos, no tendrás problema: Redis aloja, como mucho, unas decenas de miles de claves. Pero si por requisito de negocio mantienes access tokens de 24 horas y revocas masivamente (piensa en un compromiso de credenciales que afecta a miles de cuentas) puedes saturar memoria. La mitigación pasa por revocar por usuario (denylist:user:42 con marca de tiempo "desde cuándo invalidar todo lo emitido") en lugar de por jti, a costa de almacenar la marca dentro del token.

La segunda es que la caché de permisos exige invalidación coordinada. Cuando un administrador modifica los roles de un usuario, hay que invalidar perms:user:<id> inmediatamente. Si tu base de datos vive detrás de un servicio de roles, lo más limpio es publicar un evento en un canal Pub/Sub (role_changed) al que el servicio de auth se suscribe; cabe destacar que olvidar este paso produce bugs sutiles donde el usuario "ve" cambios en pantalla pero la API sigue rechazando sus llamadas durante cinco minutos.

La tercera, más filosófica: la apatridia perfecta es un mito útil. En la práctica, todos los sistemas de autenticación maduros tienen estado en algún lugar. La cuestión es elegir qué estado pagas y dónde lo colocas. JWT + Redis no es renunciar a la apatridia, sino acotarla: las verificaciones funcionan sin estado, y solo añades un viaje a Redis cuando la lógica de negocio lo exige.

Closure

El patrón JWT + Redis no es novedoso, pero sí frecuentemente mal explicado. Cuando alguien describe los JWT como "imposibles de invalidar" está, en realidad, describiendo una implementación incompleta. La firma y la apatridia son herramientas, no dogmas; combinarlas con una capa de revocación rápida produce sesiones que se sienten instantáneas para el usuario legítimo y desaparecen al instante para el comprometido. Por consiguiente, si estás diseñando autenticación desde cero, no aceptes la falsa dicotomía "stateful vs stateless": adopta la mezcla, dimensiona Redis, escribe bien el middleware y dormirás considerablemente mejor.