ecosystem-wikicómo extender los templates sin romper sus convenciones
‹ todas las recipes

Sesiones y refresh en el servidor

template-api
último cambio 2026-09-25

El lado cliente lo cuenta sesión y almacenamiento seguro. Esta página es el otro extremo: qué se guarda, dónde, y qué pasa cuando alguien presenta un token que no le toca.

El esquema es el de siempre —access corto, refresh largo y rotativo— y todo lo interesante está en los bordes.

Qué se guarda y dónde

Las sesiones viven en Redis, no en Postgres. Son efímeras por definición, caducan solas con un TTL y se escriben en cada refresco: una tabla obligaría a purgar lo que Redis olvida gratis.

Tres claves por sesión, y cada una existe por una consulta concreta:

session:{id}                  hash con los datos de la sesión
session:by-hash:{sha256}      índice: del token que llega, a su sesión
session:user:{userId}         set: todas las sesiones de un usuario

Las tres se mantienen juntas en un pipeline, porque olvidar una deja basura que nadie vuelve a mirar: un índice apuntando a una sesión que ya no existe, o un id fantasma en el set de un usuario.

El refresh se guarda hasheado, no cifrado ni en claro

return createHash("sha256").update(token).digest("hex");

SHA-256 y no Argon2, al contrario que la contraseña, y es deliberado: un refresh token es un valor aleatorio de 256 bits, no una palabra que alguien pueda adivinar. No hay diccionario que atacar, así que el coste de un hash lento no compra nada — y sí lo paga cada refresco, que es de las llamadas más frecuentes del sistema.

Lo que sí compra el hash es que un volcado de Redis no contenga credenciales utilizables.

La detección de reutilización

Esta es la parte que justifica la rotación. Al refrescar, la sesión vieja se destruye y nace otra con otro id:

const hash = this.tokenHasher.hash(refreshToken);
const session = await this.sessions.findByRefreshTokenHash(hash);

// Un refresh con firma válida que ya no corresponde a ninguna sesión activa
// significa que fue rotado: presentarlo otra vez es replay.
if (!session) {
  await this.sessions.revokeAllForUser(payload.sub);
  throw new TokenReusedError();
}
if (session.isRevoked()) {
  await this.sessions.revokeAllForUser(session.userId);
  throw new TokenReusedError();
}
if (!session.isActive(this.clock.now())) {
  // Sólo caducada, no revocada → sesión rancia normal.
  await this.sessions.revoke(session.id);
  throw new UnauthorizedError("session expired");
}

Los tres casos parecen el mismo y no lo son:

Situación Qué significa Reacción
Firma válida, sin sesión El token ya se rotó: alguien guardó una copia Revocar toda la familia del usuario
Sesión revocada Lo mismo, con la sesión aún en Redis Revocar toda la familia
Sesión caducada Un cliente que volvió tarde Revocar sólo esa sesión

La diferencia importa: si el tercer caso también revocase todo, cualquiera que abriese la app tras unas vacaciones cerraría la sesión de sus otros dispositivos. Y si los dos primeros revocasen sólo una, el ladrón seguiría teniendo las demás.

El id del usuario para revocar la familia sale del payload del token, ya verificado, no de ningún dato de la petición. Sin sesión que consultar, es lo único fiable que hay a mano.

El login no cuenta nada antes de comprobar la contraseña

const user = await this.users.findByEmailIncludingDeleted(email);

const valid = user
  ? await this.hasher.verify(user.password, cmd.password)
  : (await this.hasher.verify(DUMMY_HASH, cmd.password), false);

if (!user || !valid) throw new InvalidCredentialsError();

Dos cosas en cinco líneas:

Y de ahí sale una regla que se olvida al añadir ramas: ninguna condición de la cuenta se revela antes de validar la contraseña. La rama de cuenta desactivada va después, o el login pasa a decir “esta dirección existe y está de baja” a cualquiera.

Un paso intermedio no es una sesión

Cuando el login no puede terminar —hay segundo factor, o la cuenta está en periodo de baja— lo que se devuelve no son tokens, sino un desafío:

if (user.is2faEnabled()) {
  return { mfaRequired: true, challengeToken: this.jwt.signMfaChallenge(user.id) };
}

Un token de desafío es un JWT corto y de propósito único: lo único que puede hacer es llamar al endpoint que completa ese paso. No hay sesión todavía, así que no hay nada que revocar si se pierde, y su firma va con un secreto distinto del de acceso para que no se puedan confundir.

El mismo patrón sirve para la reactivación, y ahí hay un detalle de diseño que vale copiar: la cuenta desactivada se descubre al intentar emitir la sesión, no antes.

try {
  return await this.issueSession.execute(user, { userAgent: cmd.userAgent, ip: cmd.ip });
} catch (err) {
  if (err instanceof AccountDeactivatedError) {
    return {
      reactivationRequired: true,
      challengeToken: this.jwt.signReactivationChallenge(user.id),
      deletionScheduledFor: err.deletionScheduledFor,
    };
  }
  throw err;
}

Así la comprobación vive en un solo sitio —el caso de uso que emite sesiones— y ninguna ruta nueva puede saltársela por olvido. Es lo contrario de repetir if (user.deletedAt) en cada endpoint de entrada.

Cambiar la contraseña cierra las sesiones

Por evento, no por llamada directa: quien cambia la contraseña no tiene por qué saber que existen sesiones.

await this.sessions.revokeAllForUser(event.userId);

Aquí hay una decisión que conviene tomar a conciencia, porque las dos opciones son defendibles: esto revoca también la sesión desde la que se está cambiando la contraseña. Como política de seguridad es lo correcto —si la cambias porque sospechas que te la robaron, quieres echar a todos—, pero obliga a volver a entrar justo después de una operación que salió bien, y eso se lee como un error si la interfaz no lo explica.

Qué probar

La tentación es probar el camino feliz y dejar los bordes. Los bordes son la feature:

Qué Por qué
Un refresh ya usado revoca toda la familia Es la detección de robo entera
Una sesión caducada revoca sólo la suya Lo contrario echa a alguien de sus otros dispositivos por volver tarde
El refresco rota el id y deja el índice viejo borrado Un índice huérfano es un token que sigue funcionando
Login con dirección inexistente y con contraseña mala dan el mismo error Es lo que evita el oráculo de direcciones
Con 2FA activo el login no devuelve tokens Un desafío no es una sesión
Cambiar la contraseña deja cero sesiones activas Es la política, y se rompe en silencio

El reloj entra por un puerto (CLOCK), así que la caducidad se prueba moviendo la hora, no esperándola — ver ¿repositorio o port?.