Tokens de vida corta, rotación de refresh y cómo revocar un JWT
La forma en que suelen desplegarse los JWT —como access tokens autocontenidos que un servidor verifica localmente, sin consultar ningún estado— es justo la razón por la que no puedes recuperarlos con facilidad: son válidos hasta que caducan, hagas lo que hagas en el servidor. Este artículo trata de cómo gestionar eso: vidas cortas del access token, refresh tokens y rotación con detección de reutilización, y las opciones reales para revocar un token autocontenido, cada una con su equilibrio frente a la ausencia de estado por la que elegiste el JWT.
El artículo anterior terminó en la disposición hacia la que converge la mayoría de los equipos: un access token de vida corta junto a un refresh token bien guardado. Este trata de la maquinaria que hace funcionar esa disposición — y de la incómoda propiedad de los JWT que hace que esa maquinaria sea necesaria en primer lugar.
Aquí está esa propiedad, dicha sin rodeos. Un JWT es solo un formato — nada en él exige ausencia de estado — pero se usa a menudo como un access token autocontenido y firmado que un servidor de recursos verifica localmente: el servidor comprueba la firma y las claims y llega a un veredicto sin llamar a una base de datos ni al emisor. Eso es lo que hace el patrón rápido y fácil de escalar entre servicios. Y es también, exactamente, por lo que no puedes recuperar con facilidad un token así. Cuando lleva un exp — un token no tiene por qué, pero un access token debería — es válido hasta ese momento, y nada de lo que hagas de forma rutinaria en el servidor lo cambia, porque la verificación nunca se detiene a preguntar «¿esto sigue estando bien?». (La única excepción tosca es retirar la propia clave de firma: eso invalida de golpe todos los tokens que firmó, pero es una palanca global y burda, sujeta a la propagación por JWKS y las cachés de los verificadores, apropiada para un compromiso de clave y no para un cierre de sesión normal.) Así que las operaciones que todo sistema real necesita — desconectar a este usuario en todas partes, este token fue robado, revócalo, esta cuenta queda vetada desde ya — no tienen un hogar natural. Cada técnica de este artículo existe para gestionar ese único problema: acotar, detectar y deshacer la validez de un token que ya está en circulación.
Palanca uno: vidas cortas
El control más simple es también el más importante. Si un access token caduca a los pocos minutos de emitirse, uno robado solo es peligroso durante esos pocos minutos. No lo revocas — lo esperas, lo cual es barato y no requiere estado. Una vida corta no es revocación, eso sí: revocar significa que el servidor invalida activamente una credencial que de otro modo seguiría siendo válida, y la caducidad no hace tal cosa. Lo que hace es acotar cuánto tiempo sigue siendo útil un token filtrado, sin estado alguno en el servidor — y eso la convierte en la columna sobre la que se construye el resto del ciclo de vida. Buena parte de lo que sigue trata, en realidad, de hacer que las vidas cortas sean prácticas en vez de dolorosas.
Porque el dolor es real: un token que caduca cada pocos minutos, por sí solo, mandaría a los usuarios de vuelta al formulario de login sin parar. Necesitas una forma de entregarle al navegador un access token nuevo en silencio, sin pedir contraseña, cada vez que el actual expira. Ese mecanismo es el refresh token, y todo su diseño es una respuesta a esa tensión.
Palanca dos: el reparto entre access y refresh
Un refresh token es una credencial separada y de vida más larga cuyo único trabajo es obtener nuevos access tokens del servidor de autorización. La razón de tener dos tokens en lugar de uno es que así pueden tener perfiles de exposición opuestos, y proteges cada uno en consecuencia:
- El access token se envía en cada petición a la API, así que está expuesto ampliamente — en cabeceras, proxies, logs. Mantienes su vida corta precisamente porque está en todas partes.
- El refresh token se envía solo al endpoint de tokens, rara vez y a un único sitio, así que su exposición es estrecha. Eso te permite guardarlo con firmeza — en una cookie
httpOnlyque el JavaScript del navegador no puede leer, o en manos de un backend bajo el patrón BFF del artículo anterior.
Ese reparto es toda la clave. La credencial de vida larga es la que se transmite pocas veces y se mantiene fuera de alcance; la que viaja a todas partes es la que caduca antes de poder hacer mucho daño. Fusiona las dos en un único token de vida larga enviado en cada petición y obtienes lo peor de ambos — que es la disposición que las vidas cortas existen para evitar.
Palanca tres: rotación y detección de reutilización
Los refresh tokens introducen un riesgo nuevo — son de vida larga, así que uno robado tiene valor — y la rotación es la respuesta que convierte ese riesgo en algo que puedes detectar.
Rotar significa que, cada vez que se usa un refresh token, el servidor emite un refresh token nuevo e invalida el anterior. El refresh token pasa a ser de un solo uso. Por sí solo es una mejora modesta, pero habilita la parte que importa: la detección de reutilización. Tras una rotación, solo una parte debería tener legítimamente el refresh token actual. Así que, si alguna vez se vuelve a presentar un token ya usado y rotado, algo va mal — la cadena se ha bifurcado, y dos partes sostienen descendientes del mismo login. Esa es la firma de un robo.
Sigamos el ataque paso a paso y el mecanismo se aclara. Un atacante roba un refresh token y lo usa primero: recibe un par nuevo de access/refresh, y el token robado queda gastado. Cuando el cliente legítimo intenta más tarde refrescar con la copia que él aún tiene — el original ya invalidado —, el servidor ve resucitar un token gastado y reconoce el compromiso. Un servidor de autorización bien construido responde revocando toda la familia de tokens: cada refresh token descendiente de aquel login original se invalida de golpe, forzando una reautenticación limpia. Los tokens recién acuñados del atacante mueren junto a los de la víctima. Sé honesto con los tiempos, eso sí: la detección solo se dispara cuando el cliente legítimo intenta refrescar la próxima vez y presenta el original ya gastado. Hasta que eso ocurre, el atacante puede seguir rotando su propia rama viva — así que, para un usuario inactivo, la ventana de exposición se estira tanto como tarde en volver el cliente real. La rotación saca a la superficie el robo de forma fiable y lo corta en cuanto el cliente legítimo regresa; no garantiza contención en cuestión de minutos. Este modelo de familia de tokens con detección automática de reutilización es la guía de seguridad de OAuth 2.0 para clientes públicos — aplicaciones de página única y móviles — y es la razón por la que la rotación merece la complejidad añadida: no impide el primer uso malicioso, pero acota el daño y saca a la luz la intrusión de forma fiable.
Una advertencia honesta: la rotación puede generar falsas alarmas en redes poco fiables. Si una respuesta de refresco se pierde en tránsito, un cliente puede reintentar con el token viejo que nunca supo que había sido rotado, lo cual se ve exactamente como una reutilización. Las implementaciones lo suavizan — pero no gratis. Una ventana de gracia que sigue tolerando el token inmediatamente anterior también le da a un token viejo robado una ventana utilizable, así que ha de ser muy corta y de alcance muy ajustado; un refresco idempotente — que devuelve el mismo sucesor ante una petición reintentada en vez de rotar de nuevo — esquiva la ambigüedad de forma más limpia. En cualquier caso es un equilibrio de seguridad real frente a la detección de reutilización, algo que diseñar a propósito y no descubrir en producción.
La rotación tampoco es la única opción contemplada. La misma guía de seguridad de OAuth ofrece a los clientes públicos una opción paralela: refresh tokens vinculados al cliente (sender-constrained), atados a una clave que el cliente posee mediante DPoP o TLS mutuo, de modo que un token robado es inútil sin la clave privada correspondiente. La especificación lo plantea como una elección — vincular al cliente o rotar. Merece, eso sí, la misma honestidad que todo lo demás en esta serie: en un navegador, un XSS capaz de robar un token suele alcanzar también el material de clave, así que esto endurece frente a la interceptación de red y la reproducción offline, no frente a un script que ya corre en tu origen.
La parte difícil: la revocación
Las vidas cortas y la rotación cubren la mayoría de las situaciones, pero a veces necesitas invalidar el acceso ahora — un usuario pulsa «cerrar sesión en todos los dispositivos», un administrador desactiva una cuenta, se sabe que un token está comprometido. Aquí la ausencia de estado de los JWT deja de ser una virtud, porque no hay un «borrar» integrado. Las opciones disponibles son todas equilibrios entre lo inmediata que puede ser la revocación y cuánta ausencia de estado estás dispuesto a devolver. Hay cuatro que conviene conocer.
Apóyate en un TTL corto y revoca en la capa de refresco. Es el valor por defecto pragmático. El cliente sigue teniendo el refresh token, pero el servidor de autorización rastrea su estado — un hash guardado, un registro de familia de tokens —, y eso es lo que le permite rechazar el token más tarde; revoca ese registro y no se emitirán más access tokens para la sesión. El access token actual sigue siendo válido hasta que caduca por su cuenta — pero eso son solo unos minutos si lo has mantenido corto. No puedes matar el access token al instante, pero limitas su vida restante a minutos y cortas la hemorragia en el origen. Para la mayoría de las aplicaciones, una breve ventana de validez residual es un precio aceptable por mantener la verificación sin estado.
Mantén una denylist (lista de bloqueo). Cuando revocas un token, registra un identificador suyo — su jti si el emisor lo pone (es una claim opcional, no una garantía), o si no un hash del token o su ID de sesión — hasta el momento en que habría caducado, y haz que el verificador consulte esa lista en cada petición. Esto te da revocación casi instantánea de tokens concretos — a costa de una consulta en el camino crítico, que devuelve algo de la ausencia de estado por la que elegiste los JWT. El coste es acotado, eso sí: la lista solo contiene tokens revocados y aún no caducados, así que se mantiene pequeña y sus entradas expiran solas.
Mantén un corte de «valid-after» (válido a partir de) por usuario. En lugar de rastrear tokens individuales, guarda una sola marca por usuario, que significa «rechaza cualquier cosa emitida antes de este punto». En un cierre de sesión global, un cambio de contraseña o un veto, la avanzas; la verificación rechaza cualquier token más antiguo. Ánclala en un iat de confianza de tu propio emisor, o — con más robustez — en una claim de versión de sesión por usuario que tú incrementas, ya que el iat es opcional y las marcas de tiempo con granularidad de segundo se vuelven incómodas ante emisiones en el mismo segundo y la deriva de reloj. Es una consulta barata y cubre limpiamente el caso común de invalidar todas las sesiones de un usuario a la vez. Es tosca — todo o nada por usuario, sin granularidad de sesión única — pero pequeña y efectiva, y se combina bien con la denylist para los casos que necesitan un control más fino.
Renuncia a la ausencia de estado donde de verdad importa. Para el acceso de mayor riesgo, algunos sistemas abandonan los tokens autocontenidos por tokens de referencia opacos que el servidor de recursos introspecciona contra el servidor de autorización — revocables y con estado, aunque con su propia salvedad: los servidores de recursos suelen cachear los resultados de la introspección, así que lo instantánea que es realmente la revocación depende del TTL de esa caché y de lo rápido que se propague una revocación. El encuadre honesto es el útil: si la revocación instantánea y de grano fino es un requisito duro para un token concreto, un JWT autocontenido puede ser sencillamente la herramienta equivocada, y una sesión en servidor o un token introspeccionado es la correcta.
El hilo común de las cuatro es un único trueque: inmediatez de revocación frente a ausencia de estado. Un TTL corto compra una revocación «lo bastante buena» sin dejar de ser sin estado; las denylists y la introspección compran inmediatez reintroduciendo estado. No hay un punto universalmente correcto en esa línea — lo eliges por token, según lo que ese token pueda hacer si se abusa de él.
Qué significa realmente «cerrar sesión»
Vale la pena decirlo directamente, porque es un error tan común como consecuente: para un JWT, borrar el token en el cliente no es revocación. Si el token se filtró antes de que el usuario cerrara sesión, borrar tu propia copia no hace nada contra la copia del atacante — la suya sigue funcionando hasta que caduca. Cerrar sesión de verdad es una acción del servidor: borrar las credenciales del cliente y revocar el refresh token para que la sesión no pueda renovarse. «Cerrar sesión en todas partes» va un paso más allá — revoca todas las familias de refresh tokens para que ninguna sesión pueda renovarse. Si además necesitas que los access tokens ya en circulación dejen de funcionar de inmediato, en lugar de en su próxima caducidad, tienes que avanzar el corte de valid-after del usuario (o meterlos en la denylist) para que los servidores de recursos los rechacen activamente. Revocar solo los refresh tokens detiene únicamente la renovación; los access tokens pendientes siguen verificándose hasta caducar, a menos que algo en el camino de la petición consulte ese corte.
Duración de la sesión: límites absolutos y de inactividad
Los refresh tokens suelen llevar dos límites independientes, y ayuda tenerlos separados. Un máximo absoluto pone tope a la sesión entera — digamos, treinta días desde el login original, tras los cuales el usuario debe volver a entrar por muy activo que haya estado. Un tiempo de inactividad (idle) termina la sesión si pasa sin usarse durante un lapso más corto. Ese tiempo es una política del servidor, no un efecto automático — registras cuándo se usó por última vez el refresh token y lo rechazas en cuanto el hueco supera la ventana (la guía de seguridad de OAuth lo plantea como caducar un refresh token tras un periodo de inactividad del cliente). La rotación es lo que lo hace cómodo de aplicar: cada rotación es un momento natural para estampar una nueva marca de «último uso» y un nuevo plazo de inactividad en el token sucesor, de modo que una sesión que sigue refrescando vive y una latente se apaga. Juntos, los dos límites acotan tanto «cuánto puede durar como máximo una sesión» como «cuánto puede permanecer latente antes de que desconfiemos de ella».
Una pequeña nota práctica que enlaza con el primer artículo: no afines las vidas hasta el punto de pelear con el reloj. Un verificador puede configurarse con un pequeño margen de desfase de reloj sobre exp/nbf — la especificación lo permite en vez de exigirlo, y suele mantenerse en un par de minutos como mucho — porque los relojes de los servidores derivan. Un access token tan corto que compite con ese desfase producirá fallos espurios, así que corto significa minutos, no segundos.
Ver el ciclo de vida en un token real
La mayoría de los fallos de ciclo de vida son invisibles hasta que lees los números de verdad, y esos números no son más que claims. Cuando un token lleva iat y exp — las claims registradas para cuándo se emitió y cuándo caduca — juntas te dicen con precisión cuánto debe vivir, pero se guardan como marcas de tiempo Unix desnudas que el ojo no puede leer. Pega un token en el inspector de JWT y esas claims se muestran como fechas reales: ves de un vistazo si tienes en la mano un access token de vida corta o uno de vida larga, y confirmas que tu emisor está acuñando de verdad las vidas que configuraste. Una sorpresa frecuente es descubrir que un token que creías que caducaba en quince minutos en realidad lleva horas — un fallo de ciclo de vida que resulta obvio en cuanto el exp es una fecha en lugar de un entero de diez dígitos, e invisible hasta entonces.
La serie, en una línea cada una
Con esto se cierra un arco de cuatro partes, y las partes solo funcionan juntas. Lee un token — decodifícalo y entiende sus claims, recordando que decodificar no es confiar. Verifícalo correctamente — deja que tu propia configuración, no la cabecera del token, decida qué aceptar. Guárdalo con cuidado — mantén la credencial de vida larga fuera del alcance del JavaScript, y trata frenar el XSS como la defensa de verdad. Y controla su ciclo de vida — vidas cortas del access token, refresh tokens que rotan y hacen detectable el robo, y una vía de revocación para cuando las vidas cortas no bastan. Falla en cualquiera de ellas y las demás no te salvan: un sistema que verifica sin un fallo pero guarda el token en localStorage con una caducidad de 24 horas y sin forma de revocarlo ha construido un cerrojo robusto y ha pegado la llave en la puerta.
El JWT en sí es una idea pequeña y casi elegante — claims firmadas que cualquiera puede decodificar, pero en las que solo un verificador con la clave esperada puede realmente confiar. Todo lo difícil de él es operativo: qué elige confiar tu verificador, dónde guarda el navegador el token, y cómo lo recuperas cuando hace falta. Acierta en eso y el token hace su trabajo en silencio. Fállalo y la criptografía nunca fue la parte que importaba.