Sesiones y refresh en el servidor
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
- El hash es el objeto en sí, con TTL igual a la vida del refresh.
- El índice por hash resuelve el refresco: llega un token y hay que encontrar su sesión sin recorrer nada.
- El set por usuario es lo que hace posible “cerrar sesión en todos los dispositivos” y la revocación en cascada de abajo.
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:
- El
DUMMY_HASHhace que una dirección que no existe cueste lo mismo que una que sí. Sin él, la diferencia de tiempo entre las dos ramas convierte el login en un comprobador de direcciones. - Un único error para los dos casos. “No existe” y “contraseña incorrecta” son indistinguibles a propósito.
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?.