wandres.dev
SESIONES Y ESTADO · Astro.session y cookies

Patrón de autenticación con sesión

Componer sesión, middleware y locals en el flujo canónico de autenticación. El login que verifica credenciales, regenera el id de sesión contra la fijación y guarda solo el userId; el middleware que en cada petición lee ese id, resuelve el usuario y lo deposita en context.locals.user; las páginas que leen locals.user sin volver a buscarlo; y el cierre de sesión que destruye la sesión. Por qué se guarda la referencia y no la copia, y dónde se protege una ruta.

⏱ 17 min

Las tres lecciones previas fueron piezas sueltas; esta las ensambla en el patrón que casi toda aplicación necesita: la autenticación. El montaje canónico tiene una coreografía precisa. El login verifica las credenciales, regenera el identificador de sesión para blindarse contra un ataque concreto, y guarda en la sesión el mínimo imprescindible: el userId. El middleware corre en cada petición, lee ese id, resuelve el usuario completo y lo deposita en context.locals.user. Las páginas leen locals.user sin volver a averiguar nada. Y el logout destruye la sesión. Cada pieza hace una cosa, y su composición es lo que produce una sesión de usuario robusta.

🎯 Al terminar esta lección sabrás
  • Verificar credenciales y guardar solo el userId en la sesión.
  • Regenerar el id de sesión al iniciar sesión para prevenir la fijación.
  • Poblar context.locals.user desde el middleware en cada petición.
  • Proteger rutas y cerrar sesión con session.destroy.

El login: verificar, regenerar, guardar la referencia

El punto de entrada natural es una action o un endpoint que recibe las credenciales. Su trabajo tiene tres actos en orden estricto. Primero, verificar: buscar al usuario y comparar la contraseña contra su hash almacenado —nunca contra un texto plano, y con un algoritmo lento como argon2 o bcrypt, materia que excede esta lección pero que se da por hecha—. Segundo, y crucial, regenerar el id de sesión con session.regenerate. Tercero, guardar en la sesión la identidad, y aquí la disciplina es guardar la referencia, no la copia: el userId, no el objeto de usuario entero.

// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';

export const server = {
  entrar: defineAction({
    accept: 'form',
    input: z.object({ email: z.string().email(), password: z.string() }),
    handler: async ({ email, password }, context) => {
      const usuario = await buscarUsuario(email);
      if (!usuario || !(await verificarHash(password, usuario.hash))) {
        throw new Error('Credenciales invalidas');
      }
      context.session?.regenerate();            // nuevo id: anti-fijacion
      context.session?.set('userId', usuario.id); // guarda la referencia, no el objeto
      return { ok: true };
    },
  }),
};

¿Por qué solo el id? Porque el objeto de usuario —su nombre, su rol, su avatar— es un dato derivado que puede cambiar en la base de datos en cualquier momento. Si lo copiaras a la sesión, congelarías una foto que envejece: un usuario degradado de administrador seguiría siéndolo en su sesión hasta que caducara. Guardando solo el id, cada petición resuelve el usuario fresco desde la fuente de verdad. La sesión responde a quién eres; los detalles de qué eres se recalculan.

El middleware puebla locals.user

El middleware es el cuello de botella por el que pasa cada petición, y por eso es el sitio exacto para traducir el userId guardado en un objeto de usuario listo para usar. Lee la sesión, resuelve el usuario y lo deposita en context.locals.user —el maletín efímero por petición que estudiamos en el nivel del middleware—.

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

export const onRequest = defineMiddleware(async (context, next) => {
  const userId = await context.session?.get('userId');
  context.locals.user = userId ? await cargarUsuario(userId) : null;
  return next();
});

Para que locals.user esté tipado en las dos puntas, declara su forma una vez en src/env.d.ts con el espacio de nombres App.Locals. A partir de ahí, cualquier página que lea Astro.locals.user recibe autocompletado y la obligación de contemplar el caso nulo.

// src/env.d.ts
declare namespace App {
  interface Locals {
    user: { id: string; nombre: string; rol: 'admin' | 'user' } | null;
  }
}

El reparto de responsabilidades es limpio: la sesión guarda el hecho persistente —este visitante es el usuario tal— y el middleware lo convierte, en cada petición, en un objeto rico que vive en locals solo mientras dura esa petición. La página, aguas abajo, se vuelve declarativa: pinta según Astro.locals.user sin saber que detrás hubo una cookie, un id de sesión y una consulta.

---
const { user } = Astro.locals;
---
{user ? <p>Hola, {user.nombre}</p> : <a href="/entrar">Iniciar sesión</a>}

Proteger rutas y cerrar sesión

Con locals.user resuelto, proteger una zona es leer una variable. El sitio idóneo para la guarda es el propio middleware, que puede cortar la petición antes de que llegue a una página privada y redirigir a quien no tenga sesión.

export const onRequest = defineMiddleware(async (context, next) => {
  const userId = await context.session?.get('userId');
  context.locals.user = userId ? await cargarUsuario(userId) : null;

  const esPrivada = context.url.pathname.startsWith('/panel');
  if (esPrivada && !context.locals.user) {
    return context.redirect('/entrar');
  }
  return next();
});

El cierre de sesión es el gesto inverso al login y cabe en una línea: session.destroy borra la cookie y el objeto del almacén, dejando al visitante como anónimo. Conviene exponerlo como un endpoint POST —no un enlace GET— para que no lo dispare un prefetch ni una imagen incrustada.

// src/pages/salir.ts
import type { APIRoute } from 'astro';

export const POST: APIRoute = ({ session, redirect }) => {
  session?.destroy();
  return redirect('/');
};
⚠️
Regenera el id justo al cambiar de privilegio

El session.regenerate del login no es opcional ni decorativo: es la única defensa contra la fijación de sesión. Si al iniciar sesión conservaras el mismo id que tenía el visitante anónimo, un atacante que hubiera logrado plantar ese id de antemano quedaría dentro de la sesión ya autenticada. Regenerar el id en el instante en que la confianza sube —al entrar, y también al escalar permisos— invalida cualquier id previo y cierra esa puerta. Es la próxima lección quien lo diseca; por ahora, memoriza la regla: cada vez que el privilegio cambia, el id cambia.

flowchart TD
LOGIN[login verifica credenciales] --> REG[session regenerate nuevo id]
REG --> SET[session set userId]
SET --> COOK[cookie con id opaco]
COOK --> REQ[peticiones siguientes]
REQ --> MW[middleware lee userId]
MW --> LOAD[carga el usuario fresco]
LOAD --> LOC[context locals user]
LOC --> PAG[la pagina pinta segun el usuario]
style REG fill:#f9e2af,color:#11111b
style LOC fill:#89b4fa,color:#11111b
style PAG fill:#a6e3a1,color:#11111b

Verificar

Compara la contrasena contra su hash lento. Nunca guardes ni compares texto plano.

♻️

Regenerar

session.regenerate en el login invalida el id anterior y frena la fijacion.

🔗

Guardar la referencia

En la sesion va el userId, no el objeto. El usuario se resuelve fresco en cada peticion.

🚪

Poblar y proteger

El middleware llena locals.user una vez y decide, en un punto, quien pasa.

Autenticar es componer tres primitivas, cada una en su alcance de tiempo

Lo que hace hermoso a este patrón no es ninguna de sus piezas por separado, sino cómo cada una opera en un horizonte temporal distinto y encaja con las otras como engranajes de tamaños complementarios. La sesión vive en el tiempo largo: persiste entre peticiones, entre pestañas, entre días, y su único cometido es recordar un hecho mínimo e imperecedero —la identidad del visitante— reducido a su expresión más pequeña, un id. El middleware vive en el tiempo puntual de cada petición: corre una vez, en el borde, y su cometido es traducir aquel hecho persistente en un objeto útil para el aquí y ahora, resolviendo desde la fuente de verdad todo lo que es derivado y volátil —el rol actual, el nombre actual, los permisos actuales—. Y locals vive en el tiempo efímero del renderizado: nace con la petición, transporta el usuario ya resuelto hacia las páginas, y muere sin dejar rastro cuando la respuesta se envía. Observa la elegancia de la división: el dato que debe durar se guarda donde las cosas duran y se guarda mínimo, porque cuanto menos copies menos tendrás que sincronizar; el dato que debe estar fresco se recalcula en cada petición, porque una copia vieja de un permiso es un fallo de seguridad esperando a ocurrir; y el dato ya cocinado se transporta en un canal que se descarta, porque no tiene sentido persistir lo que se puede reconstruir. La tentación perezosa —guardar el usuario entero en la sesión y ahorrarse la consulta— rompe justamente el eslabón que sostiene la corrección: confunde el hecho duradero con su representación efímera, y paga el atajo con sesiones que mienten sobre quién eres ahora. Autenticar bien es, en última instancia, un ejercicio de asignar a cada dato el alcance temporal que le corresponde, y dejar que la composición de las tres primitivas haga el resto. Cuando el reparto es correcto, la seguridad no es un añadido nervioso: es una consecuencia de la estructura.

⚔️ Monta el flujo completo de sesión de usuario
  1. Escribe una action entrar que verifique credenciales, llame a session.regenerate y guarde userId en la sesión.
  2. En el middleware, resuelve el usuario desde userId y deposítalo en context.locals.user, con su forma declarada en App.Locals.
  3. Protege una ruta bajo /panel redirigiendo a /entrar cuando locals.user sea nulo.
  4. Añade un endpoint POST /salir que llame a session.destroy y comprueba que la zona privada vuelve a estar cerrada.