Sesiones y cookies
Deja tu mochila en un casillero del gimnasio y te dan una llave numerada. La llave no dice qué hay adentro, no vale nada por sí sola, y no le sirve a nadie que no esté parado justo en ese vestidor.
Eso es una cookie de sesión. La parte valiosa se queda con el servidor; el navegador solo lleva un número.
Qué guarda cada lado
El servidor mantiene una sesión: un pequeño paquete de datos que representa a una persona con la sesión iniciada.
{
sessionId: 'a3f9c2e7b418',
userId: 4821,
role: 'teacher',
expires: '2026-09-05T14:30:00Z',
}Eso es el casillero. Nunca sale del servidor.
El navegador recibe una cookie que lleva el id de la sesión y nada más:
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })Esa es la llave. El navegador la guarda sin que se lo pidan y la envía automáticamente en cada solicitud a ese dominio, así que mantener la sesión iniciada no requiere que escribas ningún código de tu parte.
Lo cual es conveniente, y también explica por qué una cookie enviada al lugar equivocado es un problema tan grande. Va a todas partes donde va ese dominio.
express-session crea la sesión, la almacena, configura la cookie, y te entrega req.session para que la leas y escribas. Y precisamente por eso siguen ocurriendo los errores que vienen a continuación. El framework decide dónde viven las sesiones y cómo se configuran las cookies; tú decides qué va dentro de ellas y cuánto tiempo duran.
crypto.randomBytes cumple con esto, Math.random no, y un id que cualquiera pueda predecir es una cuenta que cualquiera puede ocupar sin necesitar una contraseña. La cookie también necesita tres atributos que el framework no elegirá por ti. HttpOnly impide que JavaScript la lea, así que un payload de XSS no puede robar la sesión.
Secure evita que viaje por HTTP sin cifrar. SameSite controla si se envía en solicitudes entre sitios, lo cual es la defensa contra la falsificación de solicitudes entre sitios (CSRF): otro sitio hace una solicitud a la que tu navegador le adjunta la cookie.
No asumas un valor por defecto en ese último atributo. Chromium trata un SameSite sin definir como Lax; Firefox no, y el bug 1617609 de Mozilla se resolvió como WONTFIX con "cuando no se define el atributo SameSite, usamos None por defecto". La protección de Safari viene del bloqueo de cookies de terceros en su lugar. Configúralo explícitamente, y recuerda que SameSite=None requiere Secure.
El recorrido completo del inicio de sesión
Seis pasos, y solo los dos primeros involucran una contraseña:
- El usuario envía un nombre de usuario y una contraseña.
- El servidor los verifica contra un usuario almacenado.
- Si todo sale bien, el servidor crea una sesión que contiene el id del usuario y cualquier otro dato que necesite, como un rol.
- El servidor devuelve una cookie que contiene el id de la sesión.
- El navegador la guarda, y luego la adjunta a cada solicitud posterior a ese dominio.
- El servidor lee el id, busca la sesión, y así sabe quién está haciendo la solicitud.
El paso seis se repite durante toda la vida de la sesión. Esa consulta es lo que define el carácter del modelo entero: se consulta al servidor cada vez, así que puede cambiar de opinión en cualquier momento.
Todo lo que ocurre después depende del id de sesión, así que proteger ese id importa tanto como proteger la contraseña.
Está bien para un rol que rara vez cambia; no está bien para uno que puede revocarse durante un incidente. Esa decisión te corresponde a ti, no al framework.
Eso es fijación de sesión (session fixation), y la solución es regenerar el id en el momento en que cambian los privilegios: al iniciar sesión, y de nuevo ante cualquier elevación a administrador. En Express eso se hace con req.session.regenerate(), y la sesión anterior debe destruirse en lugar de simplemente abandonarse.
Dos maneras de arruinar una cookie
Meterle datos reales. La versión tentadora parece útil:
res.cookie('session', {
sessionId: 'a3f9c2e7b418',
userId: 4821,
role: 'admin',
email: '[email protected]',
password: 'hunter2',
ip: '203.0.113.42',
})Todo lo que va más allá de sessionId es un pasivo. Las cookies se interceptan, se escriben en logs, se sincronizan entre dispositivos y las puede leer cualquier JavaScript que corra en la página. Los datos privados ahora viajan a todas partes con el usuario, y un rol o id filtrado ya es suficiente para que alguien lo suplante.
En STRIDE eso es divulgación de información y suplantación (spoofing); en términos de OWASP, control de acceso deficiente.
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })Dejarla vivir para siempre. Una cookie sin maxAge ni fecha de expiración muchos navegadores la conservan indefinidamente:
res.cookie('sid', 'a3f9c2e7b418')Una cookie robada entonces sirve durante todo el tiempo que el navegador la conserve, y alguien cuyo acceso debería haber terminado sigue teniendo una llave funcional. Eso es elevación de privilegios, y en términos de OWASP, una falla de autenticación. Treinta minutos es un punto de partida razonable:
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })Lo difícil es darse cuenta, porque una cookie llena de datos útiles y una cookie que nunca expira funcionan perfectamente bien hasta el día en que dejan de hacerlo.
maxAge de Express está en milisegundos, mientras que el atributo Max-Age en el encabezado HTTP está en segundos, así que un 1800 en el lugar equivocado son treinta minutos o menos de dos segundos, según cuál hayas querido decir. En una revisión, la pregunta para cualquier cookie es qué contiene y cuándo expira. Dos cosas que revisar, y todo lo demás es detalle.
La combinación que vale la pena conocer es un tiempo de espera por inactividad más uno absoluto: treinta minutos de inactividad, y un tope máximo de ocho o doce horas sin importar qué. Sin ese límite absoluto, una cookie robada que el atacante mantiene "viva" con su propio tráfico nunca expira.
Dos maneras de arruinar una sesión
Sobrecargarla. Una sesión está pensada para identificar a alguien, y va acumulando cosas:
{
sessionId: 'a3f9c2e7b418',
userId: 4821,
role: 'teacher',
cart: [ /* 14 items */ ],
lastFivePages: [ /* ... */ ],
theme: 'dark',
cachedPosts: [ /* ... */ ],
}Las sesiones viven en la memoria del servidor, así que esto cuesta memoria por cada usuario con sesión iniciada. Peor aún, se vuelve obsoleta: la app empieza a tomar decisiones a partir de una copia de datos que la base de datos ya cambió. STRIDE llama a eso manipulación (tampering), no porque alguien la haya editado, sino porque la decisión se apoya en algo que ya no es cierto.
{
sessionId: 'a3f9c2e7b418',
userId: 4821,
role: 'teacher',
expires: '2026-09-05T14:30:00Z',
}Dejarla vivir para siempre. El mismo error que con la cookie, un nivel más abajo y peor, porque ahora es el servidor el que confía en datos obsoletos. Una sesión sin expiración significa que una cookie robada funciona indefinidamente, que un permiso revocado nunca deja de aplicarse, y que cerrar la sesión de alguien en todas partes se vuelve imposible.
La diferencia es que un problema de cookie es visible en el navegador, mientras que un problema de sesión se queda en el servidor, donde nadie mira.
Un carrito de compras pertenece a la base de datos, donde sobrevive a un reinicio del servidor y acompaña al usuario a otro dispositivo. Ponerlo en la sesión suele significar perderlo justo en el peor momento.
La consecuencia práctica es que un despliegue cierra la sesión de todo el mundo, y detrás de un balanceador de carga la mitad de tus usuarios se desconectan al azar. Redis es la respuesta habitual. Ten cuidado con la versión: connect-redis v8 exporta RedisStore como una exportación nombrada, mientras que v6 y v7 lo hacen distinto, y eso atrapa a quienes actualizan.
Los frameworks manejan bien la mecánica. Express, Django, Rails y Laravel administran el almacén y configuran valores por defecto sensatos para las cookies. Ninguno de ellos decide qué pones en la sesión ni cuánto tiempo vive, y ahí es exactamente donde viven estos cuatro errores.
Practica tú
Cuatro escenarios. Nombra los problemas y corrige cada uno.
// 1. Cookie configurada después del inicio de sesión
res.cookie('session', {
sessionId: 'a3f9c2e7b418',
userId: 4821,
password: 'hunter2',
ip: '203.0.113.42',
}, { maxAge: 1000000000000 })
// 2. Cookie configurada después del inicio de sesión
res.cookie('sid', 'a3f9c2e7b418')
// 3. Una sesión almacenada
{ sessionId: 'b7d1', userId: 91, role: 'admin', cart: [], theme: 'dark',
ip: '203.0.113.42', expires: '3000-01-01T00:00:00Z' }
// 4. Una cookie y su sesión
res.cookie('sid', { userId: 91, role: 'admin' }, { maxAge: 2000 })
{ sessionId: 'c4e2', userId: 91, role: 'admin', expires: '2099-01-01T00:00:00Z' }Compara tus respuestas
| # | Problemas | Corrección |
|---|---|---|
| 1 | Datos sensibles en la cookie, y un maxAge de unos 32 años | Deja solo sessionId, maxAge: 1800000 |
| 2 | No tiene expiración alguna, así que muchos navegadores la conservan indefinidamente | Agrega maxAge |
| 3 | Sobrecargada, y expira en el año 3000 | Deja sessionId, userId, role, con expiración a 30 minutos |
| 4 | La cookie no tiene id de sesión y en cambio lleva userId y role; su maxAge de 2 segundos es inutilizable; la sesión expira en 2099 | Pon el id de sesión en la cookie, elimina el resto, maxAge: 1800000, expiración a 30 minutos |
El escenario 4 es el interesante, porque falla en ambas direcciones a la vez. La cookie es a la vez demasiado efímera para servir de algo y lleva datos que nunca deberían salir del servidor, mientras que la sesión detrás de ella dura casi un siglo.
Si tus correcciones difieren en los detalles, no hay problema. Lo que importa es llegar al razonamiento correcto.
Hacia dónde va esto
La identidad con estado (stateful) es sólida porque el servidor mantiene el control. Cada uno de estos cuatro errores le quita algo a esa solidez: datos que se escapan del servidor, o una decisión que sobrevive a los hechos en los que se basaba.
Tokens de portador opacos empieza el arco opuesto, donde el servidor deja de mantener una sesión por completo y el cliente lleva su propia prueba.

