Refresh token JWT: cómo funciona en web y en mobile
Recorrimos el mecanismo paso a paso en un laboratorio con NestJS: dónde vive cada token en web y en mobile, cómo rota el refresh token, por qué cinco pedidos a la vez pueden cerrar una sesión y qué piden OWASP y NIST.
Un access token JWT suele vencer en minutos. Las apps que usas a diario, en cambio, pasan semanas sin pedirte la contraseña. Firebase lo dice en su documentación: sus sesiones son de larga duración y sus ID tokens duran una hora. Lo que une esas dos cifras es el refresh token, y ahí aparecen casi todos los problemas: dónde guardarlo, cómo renovarlo y qué pasa cuando dos pedidos lo usan a la vez.
En este estudio recorremos el mecanismo paso a paso, primero en web y después en mobile, porque cada plataforma lo guarda y lo envía de forma distinta. Probamos cada paso en un laboratorio con NestJS, Redis, una SPA en Chromium y un cliente nativo simulado, y las capturas de este post son sus salidas reales. El código está en github.com/idtt-pe/jwt-refresh-lab.
1. Las dos piezas: un JWT de minutos y un refresh token opaco
El access token es un JWT: tres partes en base64url separadas por puntos. El header indica con qué algoritmo se firmó. El payload lleva los claims: el usuario (sub), cuándo se emitió (iat), cuándo vence (exp), un identificador único (jti), quién lo emitió (iss) y para quién es (aud). La firma prueba que nadie lo alteró. La API lo valida con la clave pública, sin consultar ninguna base, y cualquier servicio que tenga esa clave puede hacer lo mismo.
Firmado no quiere decir cifrado. El cheat sheet de JWT de OWASP lo explica: el payload solo está codificado en base64url, así que cualquiera que tenga el token puede leer todos sus claims. En el laboratorio lo leímos con base64 -d, sin la clave. En un JWT no va nada que no mostrarías.


El refresh token tiene otro trabajo. El RFC 6749 de OAuth 2.0 lo define como la credencial que sirve para obtener access tokens nuevos, y vive mucho más que ellos. Por eso no conviene que sea un JWT que se valide solo. En el laboratorio es una cadena aleatoria de 32 bytes, opaca, y el servidor guarda en Redis únicamente su hash SHA-256, junto con el usuario, la sesión a la que pertenece, su estado y dos vencimientos: uno absoluto, que la rotación no extiende, y otro por inactividad. Quien copie la base no se lleva tokens que pueda usar.
Los valores por defecto de los proveedores dan una idea de las proporciones. En Google, el access token de un usuario vence en una hora y el refresh token deja de funcionar si pasa seis meses sin usarse. En Microsoft Entra ID, el access token dura entre 60 y 90 minutos y el refresh, 90 días (24 horas en una SPA). AWS Cognito usa una hora y 30 días.


2. El ciclo completo, paso a paso
Con las dos piezas en su lugar, el ciclo completo tiene ocho pasos. Primero, el usuario inicia sesión con sus credenciales. Segundo, la API entrega el par, el JWT y el refresh token, y guarda el hash del refresh. Tercero, el cliente guarda cada token donde corresponde a su plataforma. Cuarto, cada pedido lleva el JWT en el header Authorization: Bearer.
Quinto, el JWT vence: la API responde 401, o el cliente se adelanta porque sabe cuándo vence. Sexto, el cliente llama a POST /auth/refresh con el refresh token. Séptimo, la API rota: marca el refresh como usado y emite uno nuevo, junto con un JWT nuevo. Octavo, el cliente repite el pedido que falló.
El quinto paso tiene dos variantes. En la reactiva, el cliente espera el 401; así trabaja el Authenticator de OkHttp en Android. En la proactiva, el cliente lee el exp y refresca antes; AppAuth lo hace 60 segundos antes del vencimiento. La proactiva ahorra un viaje, pero depende del reloj del dispositivo, que puede estar atrasado, y por eso el RFC 7519 admite unos minutos de tolerancia al validar exp. Lo prudente es implementar las dos.

En la captura siguiente, el JWT del laboratorio dura 20 segundos para poder verlo vencer. Al segundo 21 la API responde 401 token expired. El refresh devuelve un par nuevo, con un refresh token distinto del anterior, y el mismo pedido vuelve a dar 200 dentro de la misma sesión.

3. En web: el JWT en memoria y el refresh en una cookie HttpOnly
En el navegador, lo que guarda JavaScript lo puede leer cualquier script que corra en la página, incluido uno inyectado con un XSS. La guía de autenticación del NIST (SP 800-63B-4, julio de 2025) recomienda no poner secretos de sesión en el almacenamiento local de HTML5 por ese riesgo. Por eso, en una SPA que habla con tu propia API, el reparto habitual es este: el JWT en una variable de memoria, que se pierde al cerrar la pestaña, y el refresh token en una cookie que JavaScript no puede tocar.
Cuando el login viene de la web, el body de la respuesta trae solo el access token. El refresh llega únicamente en la cookie, que en el laboratorio es refresh_token=…; Max-Age=604800; Path=/auth; HttpOnly; Secure; SameSite=Strict.
Cada atributo tiene una función. HttpOnly impide que JavaScript la lea con document.cookie. Secure hace que el navegador solo la envíe por HTTPS. SameSite=Strict evita que viaje en pedidos que se originan en otro sitio. Path=/auth limita su envío a las rutas de autenticación, en lugar de cada pedido a la API. Max-Age la hace durar lo mismo que el refresh token sin uso: siete días.

Lo comprobamos con Playwright en Chromium. Desde la SPA, document.cookie devuelve una cadena vacía, también en una página servida bajo /auth. El navegador sí tiene la cookie, con httpOnly: true, y localStorage y sessionStorage están vacíos. Como control, una cookie sin HttpOnly creada en esa misma ruta sí aparece.
HttpOnly impide robar el token, pero no usarlo. Un script inyectado puede hacer que el navegador llame a /auth/refresh y la cookie viajará sola. Por eso la API exige además un header propio (x-csrf: 1) y un Origin de una lista permitida. Un header propio obliga al navegador a hacer un preflight de CORS, y un sitio ajeno no lo pasa. En el laboratorio, los pedidos sin header, sin Origin y desde https://evil.example recibieron 403, y el token siguió activo, porque un pedido bloqueado no lo consume. Un XSS sigue siendo grave: no puede llevarse el token a otra máquina, pero sí usar la sesión mientras la página esté abierta.


Hay un detalle que suele olvidarse: al recargar la página se pierde la memoria y, con ella, el JWT. La SPA arranca entonces con un POST /auth/refresh, la cookie viaja sola y la sesión sigue.
El último punto es la concurrencia. Una pantalla suele lanzar varios pedidos a la vez, y si el JWT acaba de vencer, todos reciben 401 al mismo tiempo. Si cada uno llama a refresh por su cuenta, el refresh token rota con el primero y los demás llegan con uno ya usado; la sección 6 explica por qué eso cierra la sesión. En el cliente se resuelve con un solo refresh en vuelo: el primer pedido que lo necesita lo inicia y los demás esperan la misma promesa. En la prueba con Playwright, cinco llamadas simultáneas con el JWT vencido dieron cinco 401, un solo POST /auth/refresh y cinco reintentos con 200.

4. En web, mejor aún: el BFF
El IETF publicó en agosto de 2026 el RFC 10017, la buena práctica para aplicaciones OAuth en el navegador. Ordena tres arquitecturas de la más segura a la menos segura.
En el Backend for Frontend (BFF), un servidor propio actúa como cliente OAuth confidencial, guarda el access y el refresh token y le da al navegador solo una cookie de sesión. Cada pedido del frontend pasa por el BFF, que le agrega el token antes de reenviarlo a la API, así que ningún token llega al navegador. El RFC la recomienda para aplicaciones de negocio, aplicaciones sensibles y las que manejan datos personales, y pide para su cookie Secure, HttpOnly, SameSite=Strict, el prefijo __Host-Http- y una defensa CSRF.
En el token-mediating backend, el backend obtiene los tokens y guarda el refresh, pero entrega el access token al navegador. Es menos segura que el BFF y el RFC la reserva para cuando no se puede usar uno.
En la tercera, la SPA es el cliente OAuth y guarda los dos tokens en el navegador. El RFC no la recomienda para aplicaciones de negocio ni sensibles, le exige PKCE y pide al servidor rotar el refresh token o atarlo al cliente. Explica además que ningún almacenamiento del navegador protege del todo, porque un atacante con código en la página puede pedir tokens nuevos aunque no pueda leer los guardados.
El laboratorio usa un esquema propio, sin OAuth: la API emite sus tokens y el navegador recibe el refresh en una cookie HttpOnly. Se parece al BFF en que el refresh nunca queda al alcance de JavaScript, y se diferencia en que el JWT sí llega al navegador. OWASP ASVS 5.0 (requisito 10.1.1, nivel 2) pide que, con un BFF, los tokens solo sean accesibles para el backend.

5. En mobile: sin cookies, con Keychain y Keystore
Una app nativa no tiene un navegador que maneje cookies por ella, y tampoco puede guardar un secreto propio, porque quien la descarga tiene el binario. El RFC 8252 la trata como cliente público y fija dos reglas para el login. La primera es usar el navegador del sistema y no un webview propio: según el RFC, desde un webview la app puede registrar lo que escribe el usuario y copiar sus cookies de sesión. La segunda es usar PKCE. Google bloquea el login con su cuenta en webviews embebidos desde el 30 de septiembre de 2021. En iOS la API para ese login es ASWebAuthenticationSession y en Android, Custom Tabs; AppAuth, la librería de la OpenID Foundation, trabaja con las dos.
Después del login, el refresh token viaja en el body de cada POST /auth/refresh y el JWT en el header, igual que en web pero sin cookies ni CSRF. El JWT vive en memoria. El refresh se guarda en el almacenamiento seguro del sistema. El MASTG de OWASP lo resume así: el access token en memoria transitoria y el refresh token, que es una credencial de largo plazo, en almacenamiento local seguro, como el Keychain en iOS o el Keystore en Android.
En iOS, el Keychain ofrece atributos de accesibilidad terminados en ThisDeviceOnly: el ítem no migra a otro dispositivo ni se sincroniza por iCloud, aunque sí puede restaurarse en el mismo equipo desde su backup. En Android, el refresh se cifra con una clave del Android Keystore, que no sale del dispositivo. Ojo con una costumbre: Android deprecó EncryptedSharedPreferences en la versión 1.1.0 de security-crypto, del 30 de julio de 2025, y remite a usar el Keystore directamente. La guía de DataStore avisa, además, que sus archivos entran por defecto en el Auto Backup y en las transferencias entre dispositivos.

En mobile el refresco proactivo pesa más, porque la app pasa tiempo en segundo plano. Las librerías lo resuelven de formas distintas: AppAuth (performActionWithFreshTokens) refresca 60 segundos antes de que el token venza, el Authenticator de OkHttp reacciona al 401 y el AuthenticationInterceptor de Alamofire hace las dos cosas y encola los pedidos mientras refresca.
El cliente nativo simulado del laboratorio hace lo mismo. Refresca de forma proactiva cuando al JWT le quedan menos de cinco segundos y guarda el refresh rotado en un archivo con permisos 600, que en un teléfono sería el Keychain o el Keystore. Al reabrirse como un proceso nuevo, sin nada en memoria, lee ese refresh, pide un par y sigue sin login. Con el reloj del teléfono atrasado 30 segundos, el cálculo proactivo no ve el vencimiento: cinco pantallas reciben 401 al volver del segundo plano y el single-flight hace un solo refresh.

6. Rotación, reúso y la carrera de refresh
Un refresh token que vive semanas es un buen botín. El RFC 9700, la guía de seguridad de OAuth 2.0 del IETF publicada en enero de 2025, lo describe como un blanco atractivo porque representa todo el acceso concedido al cliente, y exige a los servidores detectar el reúso en clientes públicos de una de dos formas: atando el token al cliente o rotándolo. ASVS 5.0 lo pide desde el nivel 1 (10.4.5): con rotación, el refresh se invalida al usarse, y si llega uno ya invalidado se revocan todos los refresh tokens de esa autorización.
La rotación funciona porque un token robado termina usándose dos veces, una por el atacante y otra por el usuario. El segundo uso delata el robo aunque el servidor no sepa cuál de los dos es legítimo, y por eso revoca toda la sesión. Auth0 llama familia al conjunto de refresh tokens que descienden del primero e invalida la familia completa. En el laboratorio, presentar otra vez el refresh del login dio 401 refresh token reuse detected, family revoked, y el último refresh emitido, el del usuario, también quedó revocado.

Ese mismo mecanismo tiene una trampa. Si cinco pedidos reciben 401 a la vez y cada uno manda el mismo refresh, el primero lo rota y los otros cuatro llegan con un token ya usado. Para el servidor, eso no se distingue de un robo. En el laboratorio, cinco refresh en paralelo sin protección dieron un 200 y cuatro 401, la familia quedó revocada y el refresh del único 200 tampoco sirvió: el usuario tuvo que volver a iniciar sesión.

Hay dos defensas, y conviene tener las dos. En el cliente, un solo refresh en vuelo, como el de la sección 3. En el servidor, una ventana de gracia: durante unos segundos después de rotar, el token anterior no dispara la detección de reúso. Okta la llama grace period y la trae en 30 segundos por defecto, configurable de 0 a 60; Auth0 la llama Rotation Overlap Period y viene desactivada; Cognito admite hasta 60 segundos. En el laboratorio, con 10 segundos, los cuatro pedidos rezagados recibieron 409 already rotated, la familia siguió activa y el cliente continuó con el token del 200. A los 11 segundos, el mismo token viejo volvió a revocar la sesión.
Elegimos responder 409 en lugar de reenviar el par nuevo por dos motivos: el servidor solo guarda hashes, así que no tiene el token nuevo para reenviarlo, y emitir un par por cada rezagado abriría ramas en la familia. No todos los proveedores rotan igual. Microsoft Entra ID reemplaza el refresh en cada uso, pero su documentación aclara que no revoca el anterior.

7. Lo que piden los estándares
Reunimos los requisitos de los estándares que aplican a este mecanismo y los cruzamos con el laboratorio. OWASP ASVS 5.0.0, de mayo de 2025, es el más detallado para el servidor; el MASTG de OWASP cubre el dispositivo; NIST SP 800-63B-4 fija tiempos de reautenticación según el nivel de seguridad, y FAPI 2.0, de la OpenID Foundation, es el perfil de seguridad para banca y pagos.

De la tabla salen tres lecturas. La primera es que la rotación tiene un techo: ASVS la acepta en los niveles 1 y 2, pero en el 3 espera tokens atados al cliente con DPoP o mTLS, igual que FAPI 2.0, que además pide no rotar salvo casos excepcionales porque solo admite clientes confidenciales. Si tu producto mueve dinero, la rotación no basta.
La segunda: ASVS 7.4.1 pide que una sesión terminada no se pueda seguir usando. Con JWT, eso exige una lista de tokens revocados, una fecha de corte por usuario o rotar la clave de firma. El laboratorio no lo hace: con la sesión revocada, el JWT siguió respondiendo 200 hasta su exp, y solo el endpoint que consulta Redis respondió 401. Esa es la razón práctica para que el JWT dure minutos.
La tercera es una tensión entre dos guías. NIST recomienda que los secretos de sesión no persistan al reiniciar la app, salvo que haya prueba de posesión, y el MASTG pide guardar el refresh en almacenamiento seguro para que la app no pida login al abrirse. Atar el refresh a una clave del dispositivo cumple con las dos.

8. El laboratorio y cómo correrlo
El laboratorio tiene una API en NestJS 12 que firma los JWT con ES256 usando jose, Redis 8.2 para los refresh tokens, una SPA servida desde otro origen, un cliente nativo simulado en Node y pruebas de Playwright en Chromium. La rotación, la detección de reúso y la ventana de gracia corren en scripts Lua para que sean atómicas, y 26 tests cubren el almacén y la API.
También medimos cuánto cuesta consultar Redis en cada pedido frente a verificar solo la firma, con autocannon, 50 conexiones y tres corridas por endpoint. En la misma máquina, consultar Redis costó un 1.8 % de pedidos por segundo en promedio. El argumento para el JWT sin estado no sale de ese número, sino de que cualquier servicio puede validarlo con la clave pública sin depender del almacén de sesiones. Verificar una firma ES256 tomó 55 microsegundos.
Para correrlo hacen falta Docker y pnpm: docker compose up -d --build, pnpm install, pnpm exec playwright install chromium y bash tools/evidencia.sh, que repite todas las pruebas de este post y guarda sus salidas.

Los diagramas, las animaciones y las mediciones son de idtt. El código, las pruebas y la salida de cada corrida están en github.com/idtt-pe/jwt-refresh-lab.
Más notas del equipo
/ QUIERO ALGO SIMILAR
Tu próximo proyectoempieza hoy.
Cuéntanos qué necesitas y te decimos cómo lo resolveríamos.
HablemosO escríbenos directo → hola@identity.pe


