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
Securese descartan en HTTP simple, lo que afecta al desarrollo local. - SameSite — controla el envío entre sitios.
Strictnunca 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.Laxla 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.Nonela envía en todas partes y requiereSecure. - Path — limita la cookie a un prefijo de URL.
Path=/es lo habitual. Limitar el alcance a/appreduce 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-Ageexplícito es más claro. - Prefijo
__Host-— nombrar una cookie como__Host-sidobliga a que seaSecure,Path=/y sinDomain, 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 | Sí | 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=Laxy unMax-Ageexplí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=NonesinSecure, 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-Agepero 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.