Skip to content
This page has been auto-translated and may contain errors.View in English

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.

js
{
  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:

js
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.

JunoQué guarda cada lado Lo automático sorprende a la gente. No escribes nada para adjuntar la cookie a la siguiente solicitud; el navegador lo hace porque eso es lo que los navegadores hacen con las cookies.

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.

JunoQué guarda cada lado En una app de Express casi nunca construyes ninguno de los dos objetos a mano. 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.

JunoQué guarda cada lado El id de sesión tiene un requisito que se pasa por alto con frecuencia: debe provenir de una fuente aleatoria criptográficamente segura. 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:

  1. El usuario envía un nombre de usuario y una contraseña.
  2. El servidor los verifica contra un usuario almacenado.
  3. 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.
  4. El servidor devuelve una cookie que contiene el id de la sesión.
  5. El navegador la guarda, y luego la adjunta a cada solicitud posterior a ese dominio.
  6. 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.

JunoEl recorrido completo del inicio de sesión La contraseña aparece una sola vez, en los pasos uno y dos, y luego nunca más.

Todo lo que ocurre después depende del id de sesión, así que proteger ese id importa tanto como proteger la contraseña.

JunoEl recorrido completo del inicio de sesión El paso tres es donde suelen colarse los datos de autorización. Guardar un rol en la sesión evita una lectura a la base de datos en cada solicitud, y eso significa que la app está decidiendo permisos a partir de una copia que dejó de ser exacta en el momento en que alguien la cambió.

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.

JunoEl recorrido completo del inicio de sesión Falta un paso entre el dos y el tres, y omitirlo es una vulnerabilidad con nombre propio. Si el navegador ya tenía un id de sesión antes de iniciar sesión, y el servidor asocia el nuevo inicio de sesión a ese mismo id, un atacante que haya plantado ese id de antemano ahora tiene una sesión autenticada.

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.

Meterle datos reales. La versión tentadora parece útil:

Vulnerable
js
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.

Fixed
js
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })

Dejarla vivir para siempre. Una cookie sin maxAge ni fecha de expiración muchos navegadores la conservan indefinidamente:

Vulnerable
js
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:

Fixed
js
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })
JunoDos maneras de arruinar una cookie Ambas correcciones son de una sola línea. Ninguna necesita infraestructura nueva ni una librería.

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.

JunoDos maneras de arruinar una cookie Fíjate en las unidades. El 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.

JunoDos maneras de arruinar una cookie Una expiración corta y una buena experiencia de usuario no están en conflicto, aunque la versión ingenua lo haga parecer así. Las sesiones renovables (rolling sessions) extienden la ventana mientras hay actividad, así que alguien que está trabajando activamente permanece conectado, mientras que una sesión abandonada sigue muriendo rápido.

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:

Vulnerable
js
{
  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.

Fixed
js
{
  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.

JunoDos maneras de arruinar una sesión Ambos errores de sesión se parecen a los de la cookie, lo cual los hace más fáciles de recordar: mantenla pequeña, y dale un final.

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.

JunoDos maneras de arruinar una sesión La regla que mantiene las sesiones pequeñas: guarda identidad, no estado. Un id de usuario y tal vez un rol. Cualquier otra cosa que puedas consultar, consúltala.

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.

JunoDos maneras de arruinar una sesión El almacén de sesiones por defecto de Express es la memoria del propio proceso, y su documentación dice claramente que no está pensado para producción. Tiene fugas, muere junto con el proceso, y no existe para la instancia de al lado.

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.

js
// 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
#ProblemasCorrección
1Datos sensibles en la cookie, y un maxAge de unos 32 añosDeja solo sessionId, maxAge: 1800000
2No tiene expiración alguna, así que muchos navegadores la conservan indefinidamenteAgrega maxAge
3Sobrecargada, y expira en el año 3000Deja sessionId, userId, role, con expiración a 30 minutos
4La 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 2099Pon 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.