Saltar al contenido

Estado efímero vs persistente, flash y seguridad de sesión

Asignar a cada dato el horizonte temporal que le corresponde: efímero por petición en locals, persistente entre peticiones en la sesión, y de un solo salto en un mensaje flash. Cómo implementar flash con la semántica de leer una vez y limpiar sobre la Sessions API. Y la disciplina de seguridad que acota la ventana de validez de una sesión: fijación y su antídoto regenerate, rotación periódica del id, y expiración por inactividad y absoluta con ttl y maxAge.

Tres horizontes: petición, sesión, un solo salto

El primer horizonte es el efímero: datos que solo tienen sentido durante el renderizado de una petición. Su hogar es context.locals —el usuario ya resuelto, el idioma calculado, un identificador de traza—. Nacen cuando la petición nace, viajan del middleware a la página, y mueren con la respuesta. No se persisten porque no hace falta: se recalculan gratis en la siguiente visita.

El segundo es el persistente: datos que deben sobrevivir entre peticiones. Su hogar es la sesión —el carrito, el userId— o, para preferencias no sensibles, una cookie. Viven en el servidor, indexados por un id opaco, hasta que se borran o caducan. La regla que ata los dos horizontes ya la conoces: en la sesión guardas lo mínimo y duradero; en locals reconstruyes lo rico y volátil en cada petición.

El tercer horizonte es el más sutil: el de un solo salto. Piensa en un formulario que se envía por POST, guarda algo, y redirige a una página de confirmación —el patrón Post-Redirect-Get, que evita reenvíos accidentales al recargar—. Quieres mostrar “Guardado con éxito” en esa página de destino, pero solo una vez: si el usuario recarga, el mensaje debe haber desaparecido. Ese dato no es efímero —tiene que cruzar la redirección, sobrevivir a una petición— ni es persistente —no debe quedarse para siempre—. Vive exactamente un salto. Ese es el mensaje flash.

Flash: escribir ahora, leer una vez, limpiar

La implementación captura la semántica del salto único en tres tiempos. En la petición que procesa el POST, escribes el mensaje en la sesión. Rediriges. En la petición siguiente —el GET de destino— lees el mensaje y, en el mismo acto, lo limpias, para que no reaparezca en la próxima carga.

TypeScript
// en el handler del POST: deja el mensaje y redirigecontext.session?.set('flash', 'Guardado con éxito');return context.redirect('/perfil');
ASTRO
---// src/pages/perfil.astro: lee una vez y limpiaconst flash = await Astro.session?.get('flash');if (flash) Astro.session?.set('flash', undefined); // limpiar tras leer---{flash && <p class="aviso">{flash}</p>}

La Sessions API mantiene una superficie deliberadamente pequeña —get, set, regenerate, destroy, load— y no ofrece un borrado por clave, así que “limpiar” un flash es sobrescribirlo con undefined. El efecto es el buscado: en la siguiente carga, get devuelve undefined y el aviso no se pinta. La esencia del patrón no está en la mecánica sino en la semántica de leer consume: el acto de mostrar el mensaje es también el acto de gastarlo.

La sesión no es una base de datos

Con la comodidad de session.set llega una tentación: usar la sesión como un cajón de sastre donde meter cualquier cosa que apetezca recordar. Es un error con dos costes. El primero es de rendimiento: cada petición puede tener que leer y deserializar el objeto de sesión, así que cuanto más grande sea, más caro es cada acceso —una sesión obesa penaliza todas las peticiones del usuario, no solo la que guardó el dato—. El segundo es de coherencia: si copias a la sesión datos que ya viven en tu base de datos —el perfil, los permisos, el catálogo—, creas una segunda copia que envejece y que tendrás que sincronizar a mano cada vez que la original cambie.

La disciplina sana es tratar la sesión como un llavero, no como un almacén. Guarda referencias pequeñas y estables —un userId, un id de carrito, un token de un paso intermedio del checkout— y deja que la fuente de verdad siga siendo la base de datos, resuelta en cada petición hacia locals. Si un dato es grande, cámbialo por un id que apunte a él; si es derivado, recalcúlalo; si es efímero, no lo persistas. La sesión brilla cuando es pequeña y se vuelve un lastre cuando se hincha.

Seguridad: fijación, rotación, expiración

Una sesión segura no es solo una sesión bien guardada: es una cuyo identificador tiene una ventana de validez lo más estrecha posible. Tres disciplinas la estrechan.

La fijación de sesión es el ataque a batir. Un adversario logra que la víctima use un id de sesión que él ya conoce —plantándolo antes de que inicie sesión— y, si el servidor conserva ese mismo id al autenticar, el atacante queda dentro de la sesión ya privilegiada. El antídoto es quirúrgico: session.regenerate en el instante del login. Al cambiar el id justo cuando la confianza sube, cualquier identificador previo queda invalidado y el que el atacante conocía deja de servir. La regla general: regenera el id en todo cambio de privilegio —al entrar, al escalar permisos, al asumir otro rol—.

La rotación lleva esa idea al régimen permanente. Aunque no cambie el privilegio, regenerar el id cada cierto tiempo o cada tantas peticiones reduce el valor de un token robado: cuanto más corta su vigencia, menos vale haberlo capturado. Es una defensa en profundidad —no evita el robo, pero acota su provecho—.

La expiración pone un reloj a la sesión, y conviene distinguir dos relojes. La expiración por inactividad mata la sesión tras un periodo sin uso; se modela con el ttl de la sesión, que refrescas en cada actividad. La expiración absoluta mata la sesión a las N horas de haber nacido, haya o no actividad; la impones guardando la marca de creación en la sesión y comprobándola en el middleware. La primera protege un dispositivo olvidado; la segunda limita el daño de un token que nunca deja de usarse.

JS
// astro.config.mjs — caducidad por defecto e id cookie acotadasession: {  driver: sessionDrivers.redis({ url: process.env.REDIS_URL }),  ttl: 60 * 30,                                   // 30 min de inactividad  cookie: { name: 'sid', sameSite: 'lax', secure: true, httpOnly: true },}
TypeScript
// middleware: expiracion absoluta a las 8 horasconst creada = await context.session?.get('creadaEn');if (creada && Date.now() - creada > 8 * 60 * 60 * 1000) {  context.session?.destroy();  return context.redirect('/entrar');}

En la práctica combinas los dos relojes: una expiración por inactividad corta —del orden de treinta minutos en una aplicación sensible— que se refresca con cada uso, y un techo absoluto más generoso —ocho o doce horas— que se impone haga lo que haga el usuario. Y si ofreces un “recuérdame”, resiste la tentación de estirar sin más la sesión: lo correcto es un token de recuerdo aparte, de vida larga y revocable por separado, que reconstruye una sesión nueva y corta cuando el usuario vuelve. Mezclar “sigo conectado” con “confío plenamente en esta sesión” es la puerta por la que se cuelan los accesos que nadie recuerda haber concedido.

Hay una virtud de la sesión del lado del servidor que merece nombrarse aquí: la revocación inmediata. Como el dato de autoridad vive en tu almacén y no en un token autocontenido, borrarlo invalida la sesión al instante —para cerrar la sesión de un dispositivo robado, expulsar a un usuario o cortar un acceso comprometido basta un destroy o un borrado en el almacén—. Los tokens autocontenidos que nadie consulta no ofrecen eso: siguen siendo válidos hasta que expiran por sí solos. Poder revocar es la contrapartida de mantener estado en el servidor, y a menudo justifica de sobra su coste.