Introducción
Las sesiones JWT tienen una tensión de base: el access token debe durar poco para limitar el daño si se filtra, pero si dura poco, el usuario vive desconectándose. La solución estándar es separar los tokens: uno de corta vida que viaja en memoria y otro de larga vida (refresh) que renueva al primero.
En este artículo te guío por una implementación completa de ese flujo en una app con backend Django y frontend Vue 3, con lo que considero el nivel “enterprise”: rotación del refresh token, detección de reuso con revocación de familia, tiempo máximo de inactividad, tiempo absoluto de sesión y renovación proactiva desde el frontend que respeta la inactividad.
Arquitectura del flujo
SPA (Vue) API (Django)
│ │
│ 1. login (email + password) │
├─────────────────────────────►│ crea UserSession (familia)
│ ◄────────────────────────────┤ access (memoria) + refresh (cookie HttpOnly)
│ │
│ 2. peticiones con Bearer │
├─────────────────────────────►│
│ │
│ 3. renovación proactiva │
├─────────────────────────────►│ valida sesión, rota refresh,
│ ◄────────────────────────────┤ blacklist del anterior
│ │
│ 4. reuso detectado │
├─────────────────────────────►│ revoca toda la familia
Las reglas que seguimos:
- El access token vive solo en memoria (nunca en
localStorage) y dura 5 minutos. - El refresh token viaja en cookie
HttpOnly,SecureySameSite, inaccesible para JavaScript. - Cada renovación rota el refresh y revoca el anterior (mitiga robo por replay).
- Cada login crea una familia de sesión; si un token ya rotado se reutiliza fuera de la ventana de gracia (o un token más antiguo), se revoca toda la familia.
- La sesión expira por inactividad o por tiempo absoluto, sin importar cuánto se renueve.
Backend: el modelo de sesión
Una sesión representa una familia de refresh tokens. Guardamos el jti vigente (el identificador único del token), el session_id que viajará dentro del token y las fechas para aplicar las políticas:
class UserSession(BaseModel):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sessions")
family_id = models.CharField(max_length=64, db_index=True)
session_id = models.UUIDField(unique=True, editable=False)
current_jti = models.CharField(max_length=64, blank=True, default="")
previous_jti = models.CharField(max_length=64, blank=True, default="")
device = models.CharField(max_length=255, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
last_activity_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True, db_index=True)
Backend: emisión de tokens (login)
Al iniciar sesión creamos la sesión y escribimos fam y sid como claims del refresh token. De esa forma, cualquier token de la familia sabe a qué sesión pertenece:
class AguajeTokenObtainPairSerializer(TokenObtainPairSerializer):
@classmethod
def get_token(cls, user):
token = super().get_token(user)
session = UserSession.objects.create(
user=user,
family_id=str(uuid.uuid4()),
session_id=uuid.uuid4(),
current_jti=token.payload["jti"],
)
token["fam"] = session.family_id
token["sid"] = str(session.session_id)
return token
La cookie de refresh se fija en la respuesta con HttpOnly, Secure y SameSite configurable (Lax para mismo origen, None para orígenes separados). Si el frontend y la API viven en orígenes distintos y REFRESH_COOKIE_SAMESITE no se define, el sistema la selecciona automáticamente: un despliegue cross-origin con SameSite=Lax rompe la restauración de sesión al recargar la página, porque el navegador no envía cookies Lax en peticiones cross-site.
Backend: renovación con reuso y timeouts
El corazón de la implementación es el serializer de refresh. Antes de rotar el token, verificamos cuatro cosas:
- Que la sesión exista y no esté revocada.
- Que el
jtipresentado sea el vigente. Si no lo es, verificamos si es el token inmediatamente anterior dentro de una ventana de gracia corta (carrera entre pestañas); si no, es reuso: revocamos toda la familia. - Que no haya pasado el tiempo absoluto.
- Que no haya pasado el tiempo de inactividad.
class AguajeTokenRefreshSerializer(TokenRefreshSerializer):
def validate(self, attrs):
# verify=False: leemos claims para detectar reuso antes de que el
# blacklist del token intercepte; la verificación real (firma,
# expiración y blacklist) ocurre en super().validate().
refresh = RefreshToken(attrs["refresh"], verify=False)
session = UserSession.objects.filter(
session_id=refresh.payload.get("sid")
).first()
if session is None or session.revoked_at is not None:
raise TokenError("La sesión no es válida.")
if session.current_jti != refresh.payload.get("jti"):
is_concurrent_tab = (
session.previous_jti
and refresh.payload.get("jti") == session.previous_jti
and session.last_activity_at is not None
and timezone.now() - session.last_activity_at <= RACE_GRACE
)
if not is_concurrent_tab:
session.revoked_at = timezone.now()
session.save(update_fields=["revoked_at", "updated_at"])
raise TokenError("Reuso de refresh token detectado.")
# Carrera entre pestañas: se rota de nuevo sin revocar la familia.
refresh.set_jti()
refresh.set_exp()
refresh.set_iat()
refresh.outstand()
data = {
"access": str(refresh.access_token),
"refresh": str(refresh),
}
session.previous_jti = session.current_jti
session.current_jti = refresh.payload["jti"]
session.last_activity_at = timezone.now()
session.save(
update_fields=[
"previous_jti",
"current_jti",
"last_activity_at",
"updated_at",
]
)
return data
now = timezone.now()
if session.expires_at is not None and session.expires_at < now:
session.revoked_at = now
session.save(update_fields=["revoked_at", "updated_at"])
raise TokenError("La sesión expiró.")
if (
session.last_activity_at is not None
and session.last_activity_at + settings.SESSION_IDLE_TIMEOUT < now
):
raise TokenError("La sesión expiró por inactividad.")
data = super().validate(attrs)
session.previous_jti = session.current_jti
session.current_jti = RefreshToken(data["refresh"]).payload["jti"]
session.last_activity_at = timezone.now()
session.save(
update_fields=[
"previous_jti",
"current_jti",
"last_activity_at",
"updated_at",
]
)
return data
Un detalle que cuesta encontrar: RefreshToken() con verify=True lanza “Token is blacklisted” al instanciar un token ya revocado, lo que impediría llegar a nuestra lógica de reuso. Por eso leemos los claims con verify=False y dejamos que super().validate() haga la verificación criptográfica real (firma, expiración y blacklist).
Múltiples pestañas: la carrera de refresco
El access token vive en memoria y es por pestaña: una pestaña nueva arranca sin token y restaura la sesión con el refresh de la cookie (compartida). Eso funciona, salvo cuando la pestaña nueva renueva en el mismo instante que la original (su timer o un 401): ambas envían el mismo refresh token. Una rota y la otra presenta el token ya rotado. Sin matices, la detección de reuso lo trataría como robo y revocaría toda la familia, mandando al login las dos pestañas.
La solución tiene dos capas. En el backend, una ventana de gracia para el token inmediatamente anterior:
RACE_GRACE = timedelta(seconds=60)
Si llega el token anterior dentro de la gracia, se rota de nuevo sin revocar la familia (el fragmento de arriba). Cualquier token más antiguo, o el anterior fuera de la ventana, sigue revocando la familia.
En el frontend, Web Locks serializa el refresh entre pestañas del mismo origen, para que la carrera ni siquiera llegue al backend:
async function refreshAccessToken(): Promise<string | null> {
if (!refreshPromise) {
const run = () => requestRefreshAccessToken()
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined
const attempt = locks ? locks.request('aguaje:refresh', run) : run()
refreshPromise = attempt
void attempt.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
Solo una pestaña renueva a la vez; la segunda espera el lock y luego usa la cookie ya rotada.
Las políticas son configurables por entorno:
SESSION_IDLE_TIMEOUT = timedelta(
minutes=config("SESSION_IDLE_MINUTES", default=30, cast=int)
)
SESSION_ABSOLUTE_TIMEOUT = timedelta(
hours=config("SESSION_ABSOLUTE_HOURS", default=24, cast=int)
)
Backend: logout y limpieza
Al cerrar sesión revocamos la familia completa (no solo el token) y blacklisteamos el refresh presentado:
token = RefreshToken(refresh)
sid = token.payload.get("sid")
if sid:
UserSession.objects.filter(session_id=sid).update(
revoked_at=timezone.now(),
updated_at=timezone.now(),
)
token.blacklist()
Una tarea periódica (Celery beat) elimina los tokens expirados y las sesiones revocadas o vencidas, para que la tabla no crezca sin límite.
Frontend: renovación proactiva que respeta la inactividad
El frontend no espera a recibir un 401: renueva el access token antes de que expire. Pero si renovara con un timer fijo, la sesión sería infinita aunque el usuario no toque la computadora. La clave es renovar solo si hay actividad real.
Registramos actividad con eventos del navegador y un touch() que además renueva si el token está por expirar:
function touch() {
lastActivityAt = Date.now()
if (isAuthenticated.value && isExpiringSoon()) {
void runRenewal()
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') touch()
})
for (const event of ['pointerdown', 'keydown', 'touchstart']) {
document.addEventListener(event, touch, { passive: true })
}
La renovación agenda el siguiente refresco leyendo el exp del JWT (60 segundos antes de expirar), y se detiene si el usuario está inactivo:
async function runRenewal() {
if (Date.now() - lastActivityAt >= SESSION_IDLE_MS) {
stopKeepAlive()
return
}
const ok = await refreshSession()
if (ok) {
lastActivityAt = Date.now()
startKeepAlive()
}
}
function startKeepAlive() {
stopKeepAlive()
refreshTimer = setTimeout(() => void runRenewal(), nextRefreshDelay())
}
Si la renovación falla (sesión revocada, inactividad superada o tiempo absoluto cumplido), un handler global limpia la sesión y redirige a login:
export function setSessionExpiredHandler(handler: (() => void) | null) {
sessionExpiredHandler = handler
}
// en refreshAccessToken, al fallar:
setAccessToken(null)
sessionExpiredHandler?.()
Variables de entorno
| Variable | Default | Descripción |
|---|---|---|
SESSION_IDLE_MINUTES | 30 | Inactividad máxima antes de pedir un nuevo login. |
SESSION_ABSOLUTE_HOURS | 24 | Vida máxima de la sesión desde el login. |
VITE_SESSION_IDLE_MINUTES | 30 | Misma política en el frontend; debe coincidir con el backend. |
REFRESH_COOKIE_SAMESITE | Lax (auto None con CORS cross-origin) | None + Secure si frontend y API están en orígenes distintos. |
Pruebas
El flujo se prueba con el test client de Django. El caso más interesante es el de reuso: tras rotar, reutilizar el token viejo debe revocar la familia, incluido el token rotado:
def test_refresh_reuse_revokes_whole_family(self):
login = self.login()
rotated = self.client.post(
reverse("auth_refresh"), {"refresh": login.data["refresh"]}, format="json"
)
assert rotated.status_code == 200
# Reuso tardío (fuera de la ventana de gracia de 60 s)
session = UserSession.objects.get(user=self.user)
session.last_activity_at = timezone.now() - timedelta(minutes=5)
session.save(update_fields=["last_activity_at", "updated_at"])
fresh_client = APIClient() # sin la cookie rotada
replay = fresh_client.post(
reverse("auth_refresh"), {"refresh": login.data["refresh"]}, format="json"
)
assert replay.status_code == 401
invalidated = fresh_client.post(
reverse("auth_refresh"),
{"refresh": rotated.data["refresh"]},
format="json",
)
assert invalidated.status_code == 401
def test_previous_refresh_within_grace_is_a_concurrent_tab(self):
login = self.login()
rotated = self.client.post(
reverse("auth_refresh"), {"refresh": login.data["refresh"]}, format="json"
)
assert rotated.status_code == 200
fresh_client = APIClient() # segunda pestaña, sin cookie rotada
raced = fresh_client.post(
reverse("auth_refresh"), {"refresh": login.data["refresh"]}, format="json"
)
assert raced.status_code == 200
assert UserSession.objects.get(user=self.user).revoked_at is None
También conviene probar el rechazo por inactividad y por tiempo absoluto manipulando last_activity_at y expires_at, y verificar que el logout revoque la sesión.
Consideraciones de seguridad
- El reuso fuera de la ventana de gracia se considera señal de robo: revocar la familia obliga al usuario legítimo a iniciar sesión de nuevo, pero impide que el atacante siga renovando con tokens robados. La gracia de 60 s para el token inmediatamente anterior es el costo de soportar varias pestañas; el lock del navegador hace que esa ventana sea casi teórica.
- El access token sigue siendo válido hasta que expire (5 minutos) incluso tras revocar la familia. Es el costo aceptado de los tokens sin estado; una blocklist de access tokens sería el siguiente nivel si el riesgo lo justifica.
- La actividad se mide por renovaciones, no por request. Con una cadencia de renovación de ~4 minutos, la ventana de error del idle timeout es pequeña y evita una escritura en base de datos por petición.
- Los refresh tokens emitidos antes de este cambio no llevan el claim
sid; al desplegar, cada usuario debe iniciar sesión una vez.
Conclusión
Con esta implementación la sesión se mantiene abierta mientras el usuario trabaja, se cierra sola tras el tiempo de inactividad configurado y se protege contra la reutilización de tokens robados. No es magia: es la combinación de rotación, trazabilidad de la familia de tokens y una política de expiración explícita, aplicada en los dos lados de la aplicación.