Authentication

Autenticación por Sesiones

La autenticación por sesión mantiene la verdad en el servidor. Un ID de sesión aleatorio viaja en una cookie HttpOnly, mientras que la identidad y los permisos del usuario residen en un almacén que tú controlas y puedes revocar en cualquier momento.

beginner14 min readUpdated 16 sept 2026
server.ts
ts
// server.ts
import express from "express";
import session from "express-session";
import { RedisStore } from "connect-redis";

const app = express();

app.use(
  session({
    store: new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET!,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 1000 * 60 * 60 * 24, // 1 day
    },
  })
);

app.post("/login", async (req, res) => {
  const user = await verifyCredentials(req.body);
  if (!user) return res.status(401).json({ error: "invalid_credentials" });

  // Rotate the session id so a fixated cookie cannot be reused.
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: "session_error" });
    req.session.userId = user.id;
    res.json({ ok: true });
  });
});
Transporte
ID de sesión opaco en una cookie
Estado del servidor
Requerido (un almacén de sesiones)
Flags de la cookie
HttpOnly, Secure, SameSite
Almacén típico
Redis o una base de datos
Vida útil común
De 30 minutos a 2 semanas
Revocación
Eliminar el registro del servidor
Riesgo de CSRF
Sí, las cookies se envían automáticamente
Especificación
RFC 6265 (cookies)

Por que importa

Lo que te ofrecen las sesiones en el servidor

El estado vive en el servidor

El navegador solo guarda un identificador opaco. La identidad, los roles y los permisos permanecen en un almacén que controlas totalmente, por lo que nada sensible queda expuesto al cliente.

Revocación instantánea

Eliminar un registro de sesión cierra la sesión del usuario inmediatamente en todos los dispositivos que la compartan, sin tener que esperar a que un token expire.

Las cookies hacen el transporte

El navegador adjunta la cookie automáticamente, y HttpOnly la mantiene fuera del alcance de JavaScript, lo que elimina toda una clase de robo de tokens.

La imagen completa

Tres piezas móviles

Un registro propio, una cookie que solo transporta un ID opaco y un almacén compartido que permite a cualquier servidor encontrar dicho registro.

Registro de sesión

Almacenar

Una fila o clave que mapea un ID aleatorio a un usuario, una fecha de expiración y cualquier dato del servidor, como un carrito o un mensaje flash.

Cookie

Transportar

Un encabezado Set-Cookie entrega el ID al navegador, que lo devuelve en cada solicitud coincidente hasta que expire o sea borrada.

Almacén compartido

Escalar

Mover las sesiones fuera de la memoria del proceso hacia Redis o una base de datos permite que cada instancia lea la misma sesión.

Flujo

Cómo un login se convierte en sesión

Cada solicitud después del login repite la misma búsqueda, y el logout es simplemente un borrado.

  1. 1

    Verificar credenciales

    POST /login comprueba el email y la contraseña enviados contra el hash de la contraseña almacenada.

  2. 2

    Crear un registro de sesión

    El servidor genera un ID aleatorio y almacena una sesión que contiene el ID del usuario y una expiración en su almacén de sesiones.

  3. 3

    Enviar Set-Cookie

    La respuesta transporta el ID opaco en una cookie HttpOnly, Secure y SameSite.

  4. 4

    Devolver la cookie

    El navegador adjunta la cookie automáticamente a cada solicitud posterior al mismo sitio.

  5. 5

    Cargar la sesión

    El middleware lee el ID, lo busca en el almacén y adjunta el usuario autenticado a la solicitud.

  6. 6

    Destruir al cerrar sesión

    El logout elimina el registro del almacén y borra la cookie en la respuesta.

Una breve historia

Tres décadas de la cookie

  1. 1994

    Netscape lanza las cookies

    Un pequeño token persistente permite que un servidor HTTP sin estado recuerde quién está preguntando.

    94
  2. 1997

    Las cookies obtienen una especificación

    El RFC 2109 formaliza Set-Cookie y los atributos que aún definen el comportamiento hoy en día.

    97
  3. 2011

    RFC 6265 consolida las reglas

    La especificación de cookies se reescribe basándose en lo que los navegadores hacen realmente, no en la propuesta original.

    11
  4. 2016

    Llega SameSite

    Un nuevo atributo permite a los servidores excluir las cookies de las solicitudes cross-site y de la superficie de CSRF que estas crean.

    16
  5. 2020

    Lax se convierte en el valor predeterminado

    Chrome trata las cookies sin SameSite como Lax, cambiando silenciosamente el comportamiento de muchos sitios.

    20
  6. 2023

    Cookies particionadas

    CHIPS vincula una cookie a un sitio de nivel superior, abordando el rastreo de terceros embebido.

    23

La guia completa

Autenticación por Sesiones: Todo lo que necesitas saber

Qué es la autenticación por sesión

La autenticación por sesión es la forma más antigua y, aún así, la más común de mantener a un usuario conectado en la web. La idea es sencilla: el servidor te recuerda y el navegador guarda un comprobante.

Cuando inicias sesión, el servidor verifica tus credenciales y crea una sesión, un registro que el propio servidor almacena. Ese registro puede contener tu ID de usuario, la hora de creación, cuándo expira y cualquier estado, como un carrito de compras. Luego, el servidor envía a tu navegador un session id: una cadena aleatoria larga que identifica ese registro específico. El navegador guarda el ID en una cookie y lo envía de vuelta en cada solicitud. Un middleware en el servidor lee el ID, carga el registro y así sabe quién eres.

La propiedad definitoria es que el navegador posee un identificador opaco, no tu identidad. Es como un ticket de guardarropa, no el abrigo en sí. Si el ID se filtra, un atacante puede suplantarte hasta que tú o el servidor invaliden el registro; pero el ID por sí mismo no revela nada, no se puede decodificar y no se puede editar para otorgar permisos adicionales. Todo lo relevante reside detrás de la búsqueda en el servidor.

Esto es lo opuesto a un token autónomo como un JWT, donde la credencial lleva claims firmados y el servidor confía en ellos sin necesidad de realizar una consulta a la base de datos. La autenticación por sesión elige realizar una búsqueda a cambio de tener control. Ese intercambio es el tema central de esta guía.

El ciclo de vida de la sesión

Cada sesión sigue el mismo arco: se crea al iniciar sesión, se transporta mediante una cookie, se carga en cada solicitud y se destruye al cerrar la sesión.

Creación. Un POST /login exitoso es el único lugar donde debe nacer una sesión. Genera un id aleatorio criptográficamente —al menos 128 bits provenientes de un CSPRNG, nunca un contador o un valor predecible— y almacena un registro indexado por este. Establece una fecha de expiración. Si la aplicación soporta la opción “recordarme”, elige un tiempo de vida más largo de manera deliberada y no por accidente.

Transporte. Envía el id con un encabezado Set-Cookie. El flag HttpOnly lo mantiene fuera del alcance de JavaScript, Secure lo restringe a HTTPS y SameSite limita cuándo se adjunta a solicitudes cross-site. Sin estos flags, el id de la sesión queda expuesto a XSS y al sniffing de red.

Búsqueda. En cada solicitud posterior, el middleware lee la cookie, busca el id en el store y, o bien adjunta el usuario a la solicitud o rechaza la misma. Un id ausente o expirado significa una solicitud anónima, no un error, por lo que las páginas públicas siguen funcionando.

Destrucción. El cierre de sesión elimina el registro del store y borra la cookie. Eliminar el registro es lo que realmente finaliza la sesión; borrar la cookie es una cortesía para evitar que el navegador envíe un id muerto. También debe existir un proceso de expiración en el servidor que limpie las sesiones abandonadas para que el store no crezca indefinidamente.

El ciclo de vida es deliberadamente aburrido, y eso es una ventaja. Hay un solo lugar que crea sesiones, un solo lugar que las carga y un solo lugar que las destruye: es fácil de auditar y fácil de testear.

Las contraseñas son la primera barrera

Una sesión solo puede ser tan confiable como el inicio de sesión que la creó, por lo que el almacenamiento de contraseñas merece atención antes de que aparezca la primera cookie.

Nunca almacenes una contraseña. Almacena un hash lento y salteado (salted) producido por un algoritmo diseñado específicamente para ello, como Argon2id, bcrypt o scrypt. Estos son deliberadamente costosos, lo que convierte una filtración de base de datos —que sería un volcado instantáneo de credenciales— en una tarea de cracking larga y costosa. Un hash de propósito general como SHA-256 es la herramienta incorrecta: es rápido, que es exactamente lo que un atacante desea.

import argon2 from "argon2";

export async function hashPassword(password: string): Promise<string> {
  return argon2.hash(password, { type: argon2.argon2id });
}

export async function verifyPassword(
  password: string,
  hash: string
): Promise<boolean> {
  try {
    return await argon2.verify(hash, password);
  } catch {
    return false;
  }
}

La verificación debe ser de tiempo constante o, mejor aún, delegada a la propia comparación del algoritmo para que el tiempo de respuesta no filtre información. Lo más importante es devolver el mismo error tanto para un correo electrónico desconocido como para una contraseña incorrecta. Decir “el usuario no existe” le entrega al atacante un oráculo gratuito para la enumeración de cuentas. Una respuesta genérica invalid_credentials, con una cantidad de trabajo comparable realizada en ambos casos, no les revela nada.

Un inicio de sesión exitoso es también el momento de pensar en el rate limiting, el bloqueo tras fallos repetidos y los desafíos de autenticación multifactor. Todo esto ocurre antes de que se cree el registro de la sesión.

Qué guardar en una sesión

Un registro de sesión debe ser un puntero, no una fotocopia. La tentación de meter todo el objeto de usuario en ella es fuerte y, por lo general, es un error.

Guarda el user id y deja que la solicitud cargue el resto. Si haces caché del usuario por rendimiento, asígnale un tiempo de vida corto e invalídalo cuando haya cambios, ya que una copia obsoleta en una sesión de larga duración produce errores difíciles de reproducir: por ejemplo, un administrador al que le quitaron los permisos que conserva su rol antiguo hasta que vuelve a iniciar sesión.

Algunas cosas razonables para guardar en el servidor incluyen el user id, un rol o tenant id (cuando sea pequeño y cambie rara vez), un token CSRF, un flag de “recordarme” y estados de UI de corta duración, como un mensaje flash o un destino de redirección post-login. Evita guardar objetos grandes, secretos, tokens de otros servicios o cualquier cosa que no quieras que aparezca en un volcado de depuración.

declare module "express-session" {
  interface SessionData {
    userId: string;
    tenantId: string;
    csrfToken: string;
    flash?: { type: "info" | "error"; message: string };
  }
}

Si el payload de la sesión supera los pocos kilobytes, es una señal de que los datos pertenecen a la base de datos, indexados por el usuario y cargados bajo demanda. Las sesiones pequeñas son más rápidas de serializar, más baratas de almacenar y más fáciles de analizar.

Construyendo el stack de middleware

El manejo de sesiones se expresa mejor como una cadena pequeña y ordenada: parsear la cookie, cargar la sesión, adjuntar el usuario y, finalmente, proteger las rutas restringidas. Cada pieza cumple una sola función, lo que hace que todo el conjunto sea testeable.

import cookieParser from "cookie-parser";
import session from "express-session";

app.use(cookieParser());
app.use(
  session({
    name: "__Host-sid",
    secret: process.env.SESSION_SECRET!,
    store,
    resave: false,          // do not rewrite unchanged sessions
    saveUninitialized: false, // do not create a session for anonymous visitors
    cookie: {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      path: "/",
      maxAge: 1000 * 60 * 60 * 12,
    },
  })
);
app.use(loadUser());        // attach req.user when a session exists
app.use(csrf());            // issue and verify CSRF tokens
app.use("/api", routes);    // handlers can call requireAuth() themselves

Dos opciones suelen causar la mayor parte de la confusión. resave: false evita que el middleware escriba la sesión de vuelta en el store en cada solicitud incluso cuando nada ha cambiado, lo que ahorra un viaje de ida y vuelta (round trip). saveUninitialized: false evita que se cree una sesión para cada visitante anónimo, lo que impide que el store se llene de registros vacíos y evita establecer una cookie antes de que el usuario haya realizado cualquier acción. Ambas son las configuraciones que casi siempre querrás utilizar.

El orden es fundamental. El cookie parser debe ejecutarse antes del middleware de sesión, el middleware de sesión antes de cualquier cosa que lea req.session, y el cargador de usuarios antes de cualquier ruta que verifique req.user. El middleware de protección puede ser global o adjuntarse por ruta; adjuntarlo por ruta mantiene los endpoints públicos como tales por defecto.

SameSite en profundidad

SameSite es el atributo que la gente suele configurar mal con más frecuencia, por lo que vale la pena entender qué permite realmente cada valor.

Strict nunca envía la cookie en una solicitud cuyo sitio sea diferente al que la estableció. Si un usuario hace clic en un enlace de un correo electrónico hacia tu aplicación, llegará sin la sesión, por lo que el usuario podría aparecer como desconectado en esa primera navegación y luego conectado tras refrescar la página. Es la configuración más segura y es ideal para herramientas de administración de alto riesgo.

Lax envía la cookie en navegaciones de nivel superior (top-level navigations) que utilicen métodos seguros, pero no en POST, imágenes o iframes entre sitios. Esto cubre el caso común de seguir un enlace y permanecer conectado, mientras bloquea el clásico post de formulario CSRF. Es el valor predeterminado sensato para la mayoría de las aplicaciones web y el valor por defecto del navegador cuando se omite el atributo en las versiones modernas de Chrome.

None envía la cookie en cada solicitud entre sitios y requiere Secure. Solo es necesario para flujos reales entre sitios, como un checkout embebido o un widget servido desde un origen diferente. Cada uso de None amplía tu superficie de ataque CSRF, así que añade tokens a nivel de aplicación y confirma que el flag Secure esté presente, o de lo contrario el navegador rechazará la cookie por completo.

Un punto sutil: SameSite compara sitios, no orígenes, y la definición de un sitio es el dominio registrable. app.example.com y api.example.com son el mismo sitio, por lo que un subdominio comprometido no está protegido en absoluto por SameSite. Esta es otra razón por la cual el prefijo de cookie __Host- y una higiene estricta de los subdominios son importantes.

Implementarlo por tu cuenta o usar una librería

Es tentador implementar las sesiones a mano: establecer una cookie, mantener un Map y buscarlo. Para aprender, esto es excelente. Para producción, suele ser un error, porque los detalles que realmente importan son precisamente los que es más fácil omitir.

Una librería madura como express-session para Node, el framework de sesiones de Django o el session store de Rails te ofrece IDs firmados, valores predeterminados de cookies seguras, stores enchufables, regeneración de IDs y gestión de expiración. Aún así, debes comprender cada uno de estos mecanismos —para eso es esta guía—, pero no deberías ser la única persona que haya pensado en ellos.

Si decides construir el tuyo propio, la lista mínima de requisitos es: un ID CSPRNG de al menos 128 bits, una búsqueda en tiempo constante, cookies HttpOnly y Secure y SameSite, rotación de ID al iniciar sesión, expiración en el lado del servidor y una ruta de eliminación al cerrar sesión. Si falta cualquiera de estos puntos, tienes una vulnerabilidad, no un sistema de sesiones.

Probando la autenticación de sesión

La autenticación es crítica para la seguridad, por lo que merece pruebas que validen los casos negativos y no solo el camino feliz (happy path).

import request from "supertest";
import app from "../app.js";

test("protected route rejects anonymous requests", async () => {
  await request(app).get("/api/me").expect(401);
});

test("login sets an HttpOnly session cookie", async () => {
  const res = await request(app)
    .post("/login")
    .send({ email: "[email protected]", password: "correct-horse" })
    .expect(200);

  const cookie = res.headers["set-cookie"][0];
  expect(cookie).toContain("HttpOnly");
  expect(cookie).toContain("SameSite=Lax");
});

test("logout invalidates the session", async () => {
  const agent = request.agent(app);
  await agent.post("/login").send(credentials).expect(200);
  await agent.get("/api/me").expect(200);
  await agent.post("/logout").expect(204);
  await agent.get("/api/me").expect(401);
});

El uso de un agente que preserve las cookies permite que una sola prueba siga el comportamiento de un navegador real. Valida que el id de sesión cambie después del login, que una sesión expirada sea rechazada y que una cookie manipulada sea ignorada. Estas pruebas detectan regresiones que se pasarían por alto en una prueba manual.

Atributos de cookies que importan

El ID de sesión es tan seguro como la cookie que lo transporta. Estos atributos marcan la diferencia entre un diseño sólido y uno vulnerable.

  • HttpOnly — la cookie es invisible para document.cookie. Este es el flag más importante de todos, ya que neutraliza el robo de sesiones basado en XSS. Actívalo siempre.
  • Secure — el navegador solo envía la cookie a través de HTTPS. Sin esto, un atacante en la red puede leer el ID. Actívalo siempre en producción, y ten en cuenta que las cookies Secure se descartan en HTTP simple, lo que afecta al desarrollo local.
  • SameSite — controla el envío entre sitios. Strict nunca envía la cookie entre sitios, lo cual es lo más seguro pero rompe los enlaces entrantes que, de otro modo, te mantendrían conectado. Lax la envía en navegaciones de nivel superior, como al hacer clic en un enlace, y es el valor predeterminado adecuado para la mayoría de las aplicaciones. None la envía en todas partes y requiere Secure.
  • Path — limita la cookie a un prefijo de URL. Path=/ es lo habitual. Limitar el alcance a /app reduce ligeramente la exposición, pero rara vez ayuda lo suficiente como para justificar comportamientos inesperados.
  • Domain — controla qué hosts reciben la cookie. Omitirlo mantiene la cookie en el host exacto, que es la opción más segura. Establecer un dominio padre comparte la sesión entre subdominios, ampliando el radio de impacto de un subdominio comprometido.
  • Max-Age y Expires — cuándo debe descartarse la cookie. Una cookie de sesión que no tenga ninguno de los dos dura hasta que se cierra el navegador, lo cual a menudo no es lo que los usuarios esperan en dispositivos móviles. Un Max-Age explícito es más claro.
  • Prefijo __Host- — nombrar una cookie como __Host-sid obliga a que sea Secure, Path=/ y sin Domain, lo que evita que un subdominio sobrescriba tu cookie de sesión.
HTTP/1.1 200 OK
Set-Cookie: __Host-sid=s%3A9f2c...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400

Eligiendo un almacén de sesiones

El lugar donde residen las sesiones es la decisión que más afecta a la escalabilidad de tu aplicación.

La memoria en proceso (in-process memory) es la opción predeterminada en muchos frameworks y es adecuada para una aplicación de instancia única o un prototipo. Desaparece al reiniciar y no se puede compartir, por lo que falla en cuanto ejecutas más de un proceso.

Redis es la elección estándar para producción. Es rápido, soporta la expiración de claves de forma nativa y es compartido por todas las instancias. Las sesiones son registros pequeños de clave-valor, que es precisamente la fortaleza de Redis. Un Redis gestionado es económico y elimina la carga operativa.

Una base de datos relacional funciona bien cuando ya utilizas PostgreSQL o MySQL y quieres que las sesiones sobrevivan a una caída de Redis. Obtienes durabilidad y una inspección sencilla mediante SQL, a costa de una búsqueda más lenta a menos que la tabla sea pequeña y esté indexada por id.

Una tabla de sesiones dedicada es común en frameworks como Django y Rails. El almacén es una tabla con una clave de sesión, un payload serializado y una columna de expiración. Un índice en la clave y una tarea de limpieza periódica son el único mantenimiento requerido.

CREATE TABLE sessions (
  id         text PRIMARY KEY,
  user_id    bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
  data       jsonb NOT NULL DEFAULT '{}'::jsonb,
  expires_at timestamptz NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX sessions_expires_idx ON sessions (expires_at);

La memoria no es escalable

Vale la pena explicar claramente por qué las sesiones en memoria son un error en producción, ya que el fallo es intermitente y confuso.

Imagina dos instancias de la aplicación detrás de un load balancer sin un almacenamiento compartido. Un usuario inicia sesión y cae en la instancia A, que guarda la sesión en su propia memoria. La siguiente solicitud es redirigida a la instancia B, que no tiene registro del id, por lo que el usuario aparece como si hubiera cerrado sesión. Si vuelve a iniciar sesión, podría estar saltando entre las dos. El usuario experimenta cierres de sesión aleatorios; los logs muestran una sesión que existe en un nodo y no en el otro.

Puedes intentar solucionar esto superficialmente con sticky sessions, donde el load balancer vincula a un cliente a una sola instancia. Ayuda, pero es frágil: un despliegue, un crash o un evento de autoscaling eliminan el nodo y todas las sesiones almacenadas en él. Las sticky sessions también dificultan el escalado horizontal y complican los canary releases.

La solución real es un almacenamiento compartido. Una vez que las sesiones residen en Redis o en una base de datos, cualquier instancia puede atender cualquier solicitud, los despliegues se vuelven invisibles para los usuarios autenticados y puedes añadir capacidad libremente. Usa la memoria solo para el desarrollo local y haz que el almacenamiento sea configurable para que producción nunca lo utilice accidentalmente.

Fijación y rotación de sesiones

La fijación de sesión (session fixation) es un ataque en el que el atacante obtiene un session id que la víctima utilizará; luego, espera a que la víctima se autentique para aprovechar la sesión, que ahora tiene privilegios. El método de entrega clásico es un enlace manipulado como ?sid=attacker-known-id en un sitio que acepta un session id proporcionado por el cliente.

La defensa es sencilla y obligatoria: regenerar el session id cada vez que cambie el nivel de privilegios. Esto significa al iniciar sesión, después de restablecer una contraseña, al acceder a una vista de administrador y después de cualquier autenticación de paso adicional (step-up authentication). El id previo al login se descarta y la copia del atacante queda inutilizada.

req.session.regenerate((err) => {
  if (err) return next(err);
  // A brand new id now identifies this session.
  req.session.userId = user.id;
  res.json({ ok: true });
});

La rotación tiene un segundo beneficio: evita la reutilización del session id a lo largo del tiempo. Un id que ha sido válido durante meses es un premio mayor que uno que cambia en cada inicio de sesión. Algunos frameworks también realizan la rotación mediante un temporizador, lo que limita la ventana de tiempo en la que un id filtrado es útil.

Nunca aceptes un session id proveniente de una query string, del cuerpo de una petición o de un encabezado personalizado. La única fuente debe ser la cookie establecida por el propio servidor, y solo después de validar que el id existe en el store.

Expiración y timeouts de inactividad

Las sesiones no deben durar para siempre. Hay dos relojes importantes, y los buenos sistemas utilizan ambos.

Un idle timeout (tiempo de espera por inactividad) expira una sesión tras un periodo de inactividad, típicamente de 15 a 60 minutos para aplicaciones sensibles. Cada solicitud que llega antes del timeout refresca la expiración, por lo que un usuario activo permanece conectado mientras que una laptop abandonada pierde el acceso. Esta es la defensa principal contra una cookie robada que permanezca en un dispositivo.

Un absolute lifetime (tiempo de vida absoluto) limita la edad total de una sesión independientemente de la actividad, comúnmente de 8 a 24 horas, y menos para aplicaciones de alto riesgo. Esto obliga a una re-autenticación periódica y limita cuánto tiempo puede abusarse de una sesión comprometida, incluso si se mantiene activa.

Almacena la expiración junto con la sesión y hazla valer en cada búsqueda, no solo en la cookie. El Max-Age de una cookie es una sugerencia del lado del cliente que un atacante puede ignorar; la expiración del lado del servidor es la regla real. Al cerrar sesión, elimina el registro en lugar de simplemente marcarlo como expirado, para que el almacenamiento no acumule entradas muertas.

const session = await store.get(id);
if (!session || session.expiresAt < Date.now()) {
  await store.delete(id);
  return next(); // anonymous
}

CSRF: el coste de la autenticación mediante cookies

Debido a que los navegadores adjuntan las cookies automáticamente, una página de otro origen puede hacer que tu navegador envíe una solicitud autenticada sin que lo sepas. Esto es el cross-site request forgery: un sitio malicioso envía un formulario a https://bank.example/transfer y tu cookie de sesión viaja con él, haciendo que la solicitud parezca legítima.

SameSite es la primera línea de defensa. SameSite=Lax evita que las cookies se envíen en solicitudes POST de sitios externos, lo que bloquea el ataque clásico. Pero SameSite por sí solo no es una estrategia completa: los navegadores antiguos, algunas configuraciones de subdominios y los flujos legítimos entre sitios aún requieren protección a nivel de aplicación.

Dos patrones cubren el resto:

  • Token de sincronización (Synchronizer token). El servidor almacena un token aleatorio en la sesión y lo renderiza en los formularios o lo expone en un encabezado de respuesta. Las solicitudes inseguras deben devolver el token, y el servidor los compara en tiempo constante. Debido a que la página del atacante no puede leer el token, no puede falsificar una solicitud válida.
  • Cookie de doble envío (Double-submit cookie). El servidor establece un valor aleatorio en una cookie que no sea HttpOnly y el cliente envía el mismo valor en un encabezado. El servidor verifica que ambos coincidan. Es un método sin estado (stateless) y fácil de implementar, pero depende de que el atacante no pueda establecer cookies para tu dominio, por lo que conviene combinarlo con el prefijo __Host-.
const sent = req.get("x-csrf-token") ?? "";
const expected = req.session.csrfToken;
const ok =
  sent.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
if (!ok) return res.status(403).json({ error: "csrf_failed" });

Aplica protección CSRF a cada método que cambie el estado — POST, PUT, PATCH, DELETE — y exonera a GET, HEAD y OPTIONS, que nunca deben mutar el estado. También verifica el encabezado Origin en las solicitudes inseguras como una señal adicional y económica.

Cookies firmadas frente a cookies cifradas

Una cookie puede transportar datos por sí misma, y no solo un id, siempre que esté protegida. Existen dos mecanismos comunes que resuelven problemas diferentes.

Una cookie firmada añade un código de autenticación de mensaje basado en una clave para que el servidor pueda detectar manipulaciones. Un usuario no puede cambiar role=member por role=admin porque la firma no coincidiría. Sin embargo, la firma no oculta nada: la carga útil (payload) está en base64 y es legible para cualquier persona que tenga la cookie. La firma aporta integridad, no confidencialidad.

Una cookie cifrada (a menudo llamada cookie sellada) oculta y autentica la carga útil simultáneamente. El servidor puede almacenar una pequeña cantidad de datos de sesión en la propia cookie, eliminando la necesidad de realizar una consulta en el almacenamiento, mientras mantiene la información opaca para el cliente. Frameworks como cookie-session y las cookies cifradas de Rails utilizan este enfoque.

La desventaja es la misma que con los JWT: una cookie autónoma es más difícil de revocar y aumenta el tamaño de la solicitud en cada llamada. Un punto medio útil es el híbrido: mantener una sesión en el servidor para la identidad y los permisos, y utilizar una cookie firmada de corta duración para un flag pequeño y no sensible. Independientemente de lo que elijas, nunca pongas secretos, roles que no vuelvas a verificar u objetos grandes en una cookie.

Escalando sesiones entre servidores

Una vez que el store es compartido, las preocupaciones restantes son el rendimiento y las operaciones.

Mantén las sesiones pequeñas. Una sesión debe contener identificadores, no objetos completos. Almacenar una copia del documento del usuario implica que cada cambio en el perfil debe actualizar cada sesión, y los datos obsoletos provocan bugs confusos. Almacena el userId y carga el usuario, o cachealo brevemente.

Lee de manera eficiente. Un GET de Redis por solicitud toma microsegundos, pero con un volumen alto sigue siendo un salto de red. Muchos stacks cachean la sesión durante la vida útil de una sola solicitud para que múltiples middlewares no consulten el store repetidamente.

Gestiona las caídas del store. Decide si una interrupción de Redis debería cerrar la sesión de todos los usuarios o fallar de forma cerrada (fail closed) para las rutas protegidas. Fallar de forma cerrada es más seguro: trata un store inalcanzable como “sin sesión” para los endpoints autenticados y devuelve un error claro en lugar de conceder el acceso silenciosamente.

Limpia los datos. Establece un TTL en las claves de Redis y un DELETE FROM sessions WHERE expires_at < now() periódico para los stores basados en bases de datos. Sin una limpieza, el store crece sin límites y las búsquedas se vuelven más lentas.

Evita las sticky sessions cuando tengas un store compartido. Estas añaden complejidad operativa sin aportar beneficios y dificultan los despliegues sin tiempo de inactividad (zero-downtime deploys). Si realmente no puedes compartir un store, las sticky sessions son un parche temporal, no un diseño.

Sesiones frente a JWTs

Esta comparación surge constantemente, y la respuesta honesta es que resuelven problemas similares pero con diferentes compromisos.

Aspecto Sesión en el servidor JWT
Ubicación del estado Almacén del servidor Dentro del token
Revocación Inmediata Difícil hasta que expire
Consulta por solicitud Requerida Ninguna
Datos sensibles en el cliente Nunca Evitar; es legible
Escalabilidad Requiere almacén compartido Sin estado (stateless) por diseño
Riesgo de CSRF en el navegador Solo si se basa en cookies
Mejor uso Aplicaciones web propias APIs, servicios, móviles

Las sesiones ganan en control. Puedes revocar un dispositivo individual, listar las sesiones activas, forzar el cierre de sesión en todas partes y cambiar permisos que surtan efecto en la siguiente solicitud. Los JWT ganan en su naturaleza stateless: cualquier servicio puede verificar un token sin necesidad de una consulta compartida, lo cual es atractivo para microservicios y APIs donde el emisor y el verificador son sistemas diferentes.

Una arquitectura común y sensata utiliza ambos. El navegador recibe una cookie de sesión HttpOnly que hace referencia a una sesión en el servidor. Esa sesión puede contener un JWT de corta duración utilizado para llamar a servicios internos. La experiencia del usuario es un login normal y las llamadas internas se mantienen stateless. Para profundizar en la otra perspectiva, lee la guía de JWT.

Auditoría y observabilidad

Las sesiones son un límite de seguridad, y los límites deben ser observables. Registra los eventos que importan y ninguno de los secretos.

Registra los inicios de sesión exitosos y fallidos con el id del usuario, una fuente general como la IP y el user agent, y una marca de tiempo. Registra la creación, rotación y destrucción de sesiones. Nunca registres el session id en sí, la contraseña o el CSRF token; un session id en un archivo de log es una credencial en un archivo de log. Si necesitas correlacionar datos, registra un hash corto del id en su lugar.

logger.info({
  event: "session.created",
  userId: user.id,
  ip: req.ip,
  userAgent: req.get("user-agent"),
});

Ofrece a los usuarios una forma de ver y revocar sus sesiones activas. Una página de “sesiones” que enumere los dispositivos con un botón de cierre de sesión convierte una sospecha de compromiso en una solución de un solo clic, y es una funcionalidad que los usuarios esperan cada vez más. Para los administradores, genera alertas sobre “viajes imposibles”, picos de inicios de sesión fallidos o múltiples sesiones creadas desde una misma IP.

Mejores prácticas

  • Genera los session ids con un generador aleatorio criptográficamente seguro; nunca utilices un contador ni un valor proporcionado por el usuario.
  • Configura HttpOnly, Secure, SameSite=Lax y un Max-Age explícito en la cookie de sesión; considera utilizar el prefijo __Host-.
  • Regenera el session id al iniciar sesión y después de cada cambio de privilegios para evitar la fijación de sesión.
  • Utiliza un almacén compartido como Redis o una base de datos en producción; nunca dependas de la memoria del proceso entre instancias.
  • Implementa tanto un tiempo de espera por inactividad (idle timeout) como una vida útil absoluta en el servidor, no solo en la cookie.
  • Elimina el registro al cerrar sesión y limpia las sesiones expiradas de forma programada.
  • Añade protección CSRF a cada ruta que modifique el estado y verifica el encabezado Origin.
  • Mantén los payloads de la sesión pequeños; almacena los ids y carga el resto de la información.
  • Implementa un “fail closed” (fallo cerrado) cuando el almacén de sesiones no esté disponible en rutas protegidas.
  • Solicita re-autenticación para acciones sensibles, como cambiar la contraseña o añadir un método de pago.

Errores comunes

  • Almacenar las sesiones en la memoria del proceso y ejecutar más de una instancia.
  • Olvidar HttpOnly, exponiendo el session id a XSS.
  • Dejar SameSite=None sin Secure, provocando que los navegadores descarten la cookie silenciosamente.
  • Reutilizar el session id después del login en lugar de rotarlo.
  • Aceptar un session id desde un parámetro de consulta (query parameter) o del cuerpo de la solicitud.
  • Incluir roles y permisos en la cookie y confiar en ellos sin volver a verificarlos.
  • Configurar una cookie Max-Age pero nunca expirar el registro en el servidor.
  • Devolver un 200 con un cuerpo de error para una solicitud no autenticada en lugar de un 401.
  • Omitir la protección CSRF porque “SameSite ya se encarga”.
  • Permitir que el almacén de sesiones crezca indefinidamente sin un TTL o una tarea de limpieza.

Próximos pasos

La autenticación basada en sesiones enseña los fundamentos sobre los que se construye cualquier otro esquema de autenticación: una credencial, una búsqueda, una expiración y una forma de revocación. La comparación natural es JWT, donde los claims viajan con el cliente y la revocación se convierte en la parte difícil. Si necesitas inicio de sesión de terceros o acceso delegado, OAuth 2.0 es el estándar. Para entender la cookie en sí — Set-Cookie, SameSite y las reglas del encabezado — lee la guía de HTTP. Y para integrar el middleware en un servidor real, la guía de Node.js cubre el runtime que sustenta todo esto.

En la practica

Login, carga, logout y defensa

Cuatro archivos que juntos forman una capa de sesión completa.

routes/login.ts
import { Router } from "express";
import { verifyPassword } from "../auth/passwords.js";

const router = Router();

router.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const user = await db.user.findByEmail(email);
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    return res.status(401).json({ error: "invalid_credentials" });
  }

  // Regenerate to prevent session fixation.
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: "session_error" });
    req.session.userId = user.id;
    req.session.role = user.role;
    res.json({ id: user.id, email: user.email });
  });
});

export default router;

Dónde reside la credencial

Un ID de sesión en una cookie HttpOnly no puede ser leído por JavaScript. Un JWT en localStorage es legible por cualquier script en la página, por lo que un solo XSS se convierte en una toma de control total de la cuenta.

Preferir
res.cookie("sid", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  path: "/",
  maxAge: 86_400_000,
});
Evitar
const res = await fetch("/login", { method: "POST", body });
const { token } = await res.json();

// Any injected script can read this.
localStorage.setItem("token", token);

Comportamiento cross-site

SameSite=Lax envía la cookie en navegaciones de nivel superior y solicitudes del mismo sitio, lo cual es correcto para la mayoría de las apps. 'None' solo es necesario cuando se requiere un flujo cross-site real, y entonces debe ir acompañado de Secure y protección CSRF.

Preferir
res.cookie("sid", id, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
});
Evitar
res.cookie("sid", id, {
  httpOnly: true,
  sameSite: "none",
  // missing Secure: the browser will drop it
});

Compromisos

¿Vale la pena el estado de sesión en el servidor?

Las sesiones sacrifican un poco de infraestructura a cambio de un control instantáneo. Generalmente, es la decisión correcta para aplicaciones web de primera parte.

Strengths

  • Revocación instantánea

    Eliminar un registro de sesión termina el acceso inmediatamente. No hay una ventana donde una credencial robada siga funcionando hasta que expire.

  • Nada sensible en el cliente

    La cookie contiene un ID aleatorio, no claims ni roles. Un usuario no puede manipular sus propios permisos porque el servidor posee la verdad.

  • Natural para los navegadores

    Las cookies, las redirecciones y las páginas renderizadas en el servidor encajan sin necesidad de un baile de refresco de tokens. Hacer login es una sola solicitud.

Trade-offs

  • Necesitas un almacén compartido

    La memoria en proceso funciona en un servidor y falla en el momento en que ejecutas dos. Redis o una base de datos es efectivamente obligatorio en producción.

  • El CSRF se convierte en tu problema

    Debido a que el navegador envía la cookie automáticamente, debes añadir tokens CSRF y configuraciones de SameSite correctas. La autenticación por token en un encabezado no tiene este problema.

  • Cada solicitud toca el almacén

    Una búsqueda de sesión por solicitud añade latencia y carga. El almacenamiento en caché o las cookies de sesión firmadas de corta duración pueden reducirlo, a costa de la frescura de los datos.

Preguntas frecuentes

Preguntas frecuentes

Keep learning

Related topics from the roadmap.

$ comienza a aprender

Listo para aprender Session Auth?

Nuestro tutorial interactivo te guia a traves de Session Auth paso a paso — con quizzes y codigo real que puedes ejecutar en el navegador.