wandres.dev
AUTH Y MIDDLEWARE · sesiones y protección

Sesiones en SolidStart con useSession

El protocolo HTTP es amnésico: cada petición nace sin memoria de la anterior. Una sesión reintroduce continuidad guardando datos del usuario, cifrados y firmados, dentro de una cookie que el navegador reenvía en cada petición. En SolidStart el helper `useSession` de vinxi (la capa de h3 que da potencia a Start) gestiona ese ciclo completo: sella la cookie con un `password` de al menos 32 caracteres, la desella y verifica su firma al llegar, y expone `session.data`, `session.update` y `session.clear`. Corre solo en el servidor, porque necesita las cabeceras HTTP y un secreto que ningún cliente debe ver.

⏱ 16 min

HTTP es un protocolo sin memoria: cada petición llega huérfana, sin saber nada de las que la precedieron. Para que una aplicación reconozca al mismo usuario entre una petición y la siguiente hay que reintroducir estado, y el vehículo clásico es la sesión: un puñado de datos que viajan, cifrados y firmados, dentro de una cookie que el navegador reenvía en cada llamada. En SolidStart no montas ese mecanismo a mano —lo provee useSession, el helper de sesiones de vinxi, la capa de servidor sobre h3 que da potencia a Start—. Sella la cookie, la verifica al volver y te entrega su contenido como un objeto que lees, mutas y destruyes. Esta lección disecciona ese ciclo.

🎯 Al terminar esta lección sabrás
  • Crear una sesión con useSession de vinxi/http y entender que vive solo en el servidor.
  • Leer el estado con session.data, mutarlo con session.update y destruirlo con session.clear.
  • Comprender qué hace el password: cifra y firma la cookie para que el cliente no pueda leerla ni falsificarla.
  • Configurar el nombre y los atributos de la cookie, y guardar el secreto en una variable de entorno privada.

useSession: una sesión sellada, no solo firmada

useSession recibe una configuración con un password y devuelve una promesa del objeto de sesión. Como el secreto no debe aparecer jamás en el código, lo lees de una variable de entorno privada, y como la sesión solo tiene sentido en el servidor, la envuelves en un helper marcado con "use server". Tipar el contenido con un genérico te da autocompletado y seguridad en cada acceso posterior.

// src/lib/sesion.ts
import { useSession } from "vinxi/http";

export type DatosSesion = {
  userId?: string;
};

export async function usarSesion() {
  "use server";
  return useSession<DatosSesion>({
    password: process.env.SESSION_SECRET as string,
    name: "sesion",
    cookie: {
      httpOnly: true,      // invisible para el JavaScript del cliente
      secure: true,        // solo viaja por HTTPS
      sameSite: "lax",     // mitiga CSRF
      maxAge: 60 * 60 * 24 * 7, // una semana en segundos
    },
  });
}

Fíjate en el verbo que la documentación elige: la sesión no se «firma», se sella. h3 cifra el contenido y además lo autentica, de modo que el cliente ni puede leer lo que guardas ni puede alterarlo sin que la verificación falle al volver. Es una diferencia de fondo respecto a una cookie meramente firmada, donde el valor viaja en claro y solo se detecta la manipulación.

Leer, actualizar y limpiar

El objeto que recibes expone tres operaciones. session.data es el contenido actual —tipado con tu genérico—; session.update reescribe ese contenido y programa una nueva cookie Set-Cookie en la respuesta; session.clear la vacía y caduca la cookie. Las tres viven detrás de funciones de servidor porque tocan cabeceras HTTP que no existen en el cliente.

// src/lib/sesion.ts (continuacion)
export async function usuarioActual() {
  "use server";
  const sesion = await usarSesion();
  return sesion.data.userId ?? null;   // lectura
}

export async function anotarUsuario(userId: string) {
  "use server";
  const sesion = await usarSesion();
  await sesion.update({ userId });      // reescribe y re-sella la cookie
}

export async function olvidarSesion() {
  "use server";
  const sesion = await usarSesion();
  await sesion.clear();                 // vacia y caduca la cookie
}

session.update fusiona lo que le pasas con lo existente, así que puedes actualizar un campo sin perder los demás; y admite tanto un objeto como una función prev => nuevo cuando el valor nuevo depende del anterior. Cada update o clear emite una cabecera Set-Cookie, motivo por el que estas operaciones solo tienen sentido durante una respuesta viva del servidor.

Una precisión sobre la lectura: session.data refleja lo que llegó en la cookie de esta petición. Si actualizas la sesión, el cambio viaja en el Set-Cookie de la respuesta y lo verás resuelto en la siguiente petición, cuando el navegador reenvíe la cookie reescrita. Distinguir lo que hay en el cable de lo que hay en tu objeto en memoria evita sorpresas al encadenar operaciones dentro de una misma petición.

El password: qué cifra y qué firma

El password es la clave maestra de todo el esquema. Con él, h3 deriva las claves que cifran el contenido y que calculan la etiqueta de autenticación; sin él, la cookie sería ilegible o falsificable. Por eso la regla es estricta: mínimo 32 caracteres, generado al azar y almacenado en una variable de entorno privada, nunca incrustado en el repositorio.

# Genera un secreto fuerte y guardalo en .env como SESSION_SECRET
openssl rand -base64 32

Si no pasas name, la cookie se llama h3 por defecto; conviene darle un nombre propio para no filtrar qué herramienta hay debajo. Y rotar el secreto tiene una consecuencia deliberada: invalida de golpe todas las sesiones vivas, porque las cookies viejas ya no desellan con la clave nueva. Ese es, de hecho, tu único botón global de cierre de sesión.

flowchart LR
A[navegador manda cookie sellada] --> B[useSession desella y verifica la firma]
B --> C[session data lista para leer]
C -->|session update| D[reescribe y vuelve a sellar]
D --> E[Set-Cookie en la respuesta]
style B fill:#89b4fa,color:#11111b
style C fill:#a6e3a1,color:#11111b
style E fill:#f9e2af,color:#11111b

Server-only: la sesión no cruza al cliente

Los helpers de sesión corren solo en el servidor, y no es un capricho: necesitan las cabeceras HTTP —que en el navegador no existen— y el password, que jamás debe salir del servidor. Por eso los envuelves en "use server". La consecuencia práctica es tranquilizadora: el cliente nunca desella la cookie ni ve el secreto, solo recibe lo que una función de servidor decide devolverle.

Para llevar un dato de la sesión a la interfaz, expones una función de servidor y la lees con el patrón canónico query más createAsync del nivel de datos. El cliente pregunta «¿quién soy?» y el servidor responde con lo mínimo —un userId, un nombre—, nunca con la cookie cruda.

// El cliente nunca desella la cookie: pide el dato a una funcion de servidor
import { query, createAsync } from "@solidjs/router";
import { usuarioActual } from "~/lib/sesion";

export const getUserId = query(() => usuarioActual(), "user-id");

// En un componente cliente:
// const id = createAsync(() => getUserId());

Es la misma frontera que gobierna todo SolidStart: el secreto y la lógica sensible se quedan en el servidor, y por el cable solo viaja el resultado serializado. La sesión sellada encaja en ese modelo sin fricción, porque su contenido nace y muere del lado servidor y el cliente solo conoce su sombra.

📝
El secreto nunca cruza el cable

Que useSession sea server-only es una garantía, no un estorbo. Si el password o el contenido desellado llegaran al cliente, cualquiera podría forjar sesiones a voluntad. Manteniéndolos en el servidor, la cookie que viaja es opaca —solo el servidor que la selló sabe leerla— y el navegador se limita a transportarla de vuelta en cada petición. Cuando necesites datos de sesión en la UI, devuélvelos desde una función de servidor; nunca intentes leer ni descifrar la cookie en el cliente.

🔐

Sellada, no firmada

El password cifra y autentica el contenido: el cliente no lo lee ni lo altera sin que la verificacion falle.

🖥️

Solo servidor

useSession toca cabeceras HTTP y el secreto. Vive tras use server, en funciones de servidor y rutas de API.

🧩

data, update, clear

Leer el estado, fusionar cambios reescribiendo la cookie, y vaciarla. Todo el ciclo en tres operaciones.

⚠️
La cookie no es un almacén: guarda un identificador, no medio mundo

Una cookie tiene un límite práctico de unos 4 KB y viaja en cada petición al dominio, así que llenarla de datos encarece todo el tráfico. Guarda lo mínimo —un userId, quizá un rol— y busca el resto en tu base de datos cuando lo necesites. Y recuerda el reverso de una sesión autocontenida: como el servidor no guarda copia, no puedes revocar una cookie concreta desde el servidor sin añadir un campo de versión que compares, o sin migrar a sesiones con almacén. Con solo el password, tu única revocación global es rotarlo, lo que echa a todos a la vez.

La cookie ES la sesión: autoridad por criptografía, no por búsqueda

El giro conceptual que hay que interiorizar es que una sesión sellada traslada el almacén de sesiones al propio cliente. En el modelo clásico de sesión en base de datos, la cookie guarda un identificador opaco y el servidor mantiene, en su propio almacén, la tabla que asocia ese identificador con los datos del usuario; la confianza nace de una búsqueda —el servidor mira su tabla y decide si te conoce—. En el modelo de useSession no hay tabla: el dato del usuario viaja dentro de la cookie, y la confianza nace de la criptografía —el servidor confía en la cookie porque él mismo la selló con un secreto que nadie más posee, y lo comprueba desellándola—. Esa diferencia tiene consecuencias de arquitectura enormes. Sin almacén de sesiones, tu backend es apátrida: cualquier instancia detrás de un balanceador puede atender cualquier petición sin compartir estado, porque toda la autoridad viaja con el cliente y se valida con la clave que todas comparten. Escalas horizontalmente sin pegar sesiones a un servidor concreto ni montar un Redis compartido. El precio de esa elegancia es simétrico: pierdes la revocación instantánea que te daba la tabla —no puedes «borrar la fila» de una sesión que ya no vive en ningún sitio— y aceptas el techo de tamaño de la cookie. Entender este eje —autoridad por búsqueda frente a autoridad por firma— es lo que te deja elegir con criterio: sesión sellada cuando priorizas escalado y simplicidad; identificador más almacén cuando necesitas revocar al instante y guardar más de lo que cabe en 4 KB. useSession es la primera; el resto de este nivel construye la autenticación sobre ella.

⚔️ Sella, lee y destruye una sesión
  1. Crea src/lib/sesion.ts con un helper usarSesion tipado que lea SESSION_SECRET y nombre la cookie sesion; confirma en las DevTools que la cookie sale como httpOnly y Secure.
  2. Escribe anotarUsuario y comprueba que tras llamarla la respuesta trae una cabecera Set-Cookie con el nuevo valor sellado, ilegible a simple vista.
  3. Lee session.data.userId desde otra función de servidor y verifica que persiste entre peticiones sin que tú guardes nada en el servidor.
  4. Llama a session.clear, observa que la cookie caduca, y razona por qué el userId desaparece sin borrar ninguna fila en ninguna parte.
  5. Cambia SESSION_SECRET por otro valor y explica por qué todas las sesiones abiertas dejan de validar de golpe: acabas de descubrir tu único botón global de logout.