1. Introducción
Cuando te enfrentas por primera vez al problema de «el inicio de sesión parece exitoso, pero la siguiente solicitud vuelve a ser anonymous», es muy tentador culpar a Spring Security, los filtros, la configuración, o incluso a Mercurio retrógrado. En la práctica, sin embargo, a menudo la culpa no la tienen los filtros, sino las cookies y sus atributos: el navegador no guardó JSESSIONID, o no la envió en la siguiente solicitud, o fue robada (si es realmente grave). Por lo tanto, un desarrollador backend debe comprender al menos tres atributos: HttpOnly, Secure, SameSite. Esto no es «cosa de frontend» ni «configuración del navegador», es parte de cómo funciona realmente tu autenticación en el mundo real.
Imagina que el preflight ya pasa, el navegador deja de quejarse de Access-Control-Allow-*, pero la siguiente solicitud sigue llegando como anonymous. En este punto, el problema a menudo ya no es CORS, sino si el navegador guardó JSESSIONID y si quiere enviarla de vuelta a http://localhost:8080.
Las cookies en nuestro modelo stateful son como «una etiqueta en la llave del casillero». El casillero en sí (la sesión) está en el servidor, pero la etiqueta la tiene el cliente. Pierdes la etiqueta: el cliente no encuentra su casillero. Repartes etiquetas con demasiada generosidad: pueden llevárselas quienes no deben. Y si la etiqueta se envía «al lugar equivocado» o «de la manera incorrecta», obtienes errores extraños que parecen «Spring Security se rompió».
2. JSESSIONID y protección de sesión
Es importante comenzar con la mecánica básica; de lo contrario, SameSite y HttpOnly parecerán magia. En el modelo basado en sesión, después del inicio de sesión, el usuario no se «almacena en el navegador». En el servidor se crea (o actualiza) una HttpSession, y el navegador recibe solo un identificador, generalmente en la cookie JSESSIONID. En cada solicitud posterior, el navegador adjunta Cookie: JSESSIONID=..., el servidor encuentra la sesión, restaura el SecurityContext, y la aplicación «recuerda» quién eres.
Desde el punto de vista de las amenazas, JSESSIONID no es «solo una cadena técnica». Es casi como una contraseña durante la vida de la sesión. Si un atacante roba JSESSIONID, puede «entrar» como el usuario sin conocer la contraseña. Esta es la historia clásica del session hijacking – y precisamente por eso hay tantas «banderas» alrededor de las cookies.
Aquí hay un diagrama simplificado de nuestro flujo habitual (usando el proyecto Secure Content Platform API como ejemplo):
sequenceDiagram
participant B as Navegador
participant API as API
B->>API: Solicitud de inicio de sesión
API-->>B: Redirección con cookie de sesión
B->>API: Solicitud de perfil con cookie de sesión
API-->>B: Respuesta del perfil
Para fijar visualmente dónde están las «banderas», es útil ver un encabezado típico Set-Cookie (ejemplo condicional, valores diferentes):
Set-Cookie: JSESSIONID=9A1B2C3D...; Path=/; HttpOnly; Secure; SameSite=Lax
Y un pequeño endpoint (muy didáctico) que ayuda a verificar que la cookie realmente llega en la solicitud. Este endpoint no tiene por qué vivir en el proyecto final, pero para entender es una excelente «radiografía»:
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DebugCookieController {
@GetMapping("/api/debug/cookies/session")
String sessionCookie(@CookieValue(name = "JSESSIONID", required = false) String jsessionId) {
// Importante: la cookie puede no estar presente (por ejemplo, primera solicitud o el navegador no la envió)
// Por eso hacemos required = false explícitamente y manejamos null
// Y aún más importante: no registramos ni devolvemos el valor real del id de sesión (es una credencial)
return jsessionId == null ? "no JSESSIONID cookie" : "JSESSIONID present";
}
}
Tenga en cuenta: no imprimimos el valor de la cookie. En un proyecto real, esto ya sería una posible fuga. Nos basta con saber si está o no.
3. HttpOnly: qué hace y qué no hace
HttpOnly suena como si la cookie «se volviera solo para HTTP y no participara en nada más». Debido a esto, muchos principiantes sacan la conclusión incorrecta: «Si la cookie es HttpOnly, significa que el navegador no la enviará automáticamente, y CSRF no es un peligro». Por desgracia, esto es incorrecto, y es importante corregir este malentendido de inmediato, antes de que crezca hasta convertirse en un error grande y costoso.
En realidad, HttpOnly dice lo siguiente: JavaScript en la página no puede leer la cookie a través de document.cookie. Eso es todo. Mientras tanto, el navegador puede seguir enviando tranquilamente la cookie al servidor en el encabezado Cookie. Es decir, HttpOnly ayuda a reducir el riesgo de fuga de cookies en caso de XSS (cuando un atacante inyecta JavaScript), pero no desactiva el envío automático de la cookie y no invalida el modelo CSRF.
Paradójicamente, esta misma regla ayuda a entender un matiz importante de la vida real: a veces se necesitan dos tipos diferentes de cookies. Por ejemplo, quieres que JSESSIONID tenga HttpOnly=true (casi siempre sí), pero el token CSRF, si lo almacenas en una cookie para un cliente SPA, a veces debe tener HttpOnly=false; de lo contrario, JavaScript no podrá leer el token y colocarlo en un encabezado. Esto no es un «agujero», son roles diferentes. La sesión es una credencial, el token CSRF es un «comprobante» técnico que el cliente debe poder enviar explícitamente.
Cómo se configura esto para la session cookie en Spring Boot, generalmente de la manera más sencilla a través de la configuración:
server:
servlet:
session:
cookie:
http-only: true # Prohíbe la lectura de la cookie de sesión desde JavaScript (protección contra fugas por XSS)
Si no has establecido esto, a menudo estará activado por defecto, pero en temas de seguridad es útil ser explícito: «Este es el invariante del proyecto: la cookie de sesión no se puede leer desde JS».
4. Secure: cookie solo por HTTPS
Con Secure suele ocurrir una típica «trama detective»: activaste el atributo seguro, te alegras, te sientes un ingeniero adulto… y de repente el inicio de sesión «no se mantiene». En el navegador ingresaste usuario/contraseña, el servidor respondió, pero en la siguiente solicitud vuelves a ser anonymous. Parece que SecurityContext no se guarda. En realidad, la cookie simplemente no se envía porque tiene Secure y estás probando con http://.
Secure significa: el navegador envía la cookie solo por HTTPS. Esto protege contra la interceptación de cookies en la red. Y sí, es una protección importante: transmitir el id de sesión por HTTP abierto es casi como llevar un pase colgado al cuello con la inscripción «por favor, róbame».
Pero en el desarrollo local, a menudo ejecutamos el proyecto en http://localhost:8080. Si pones secure=true para la cookie de sesión, algunos navegadores comenzarán a ignorarla o no enviarla, y obtendrás el efecto de «la sesión no funciona».
Una buena práctica para un proyecto de aprendizaje es crear perfiles: local (local) y de producción condicional (prod), donde los valores difieran. Por ejemplo:
# application.yml (base – seguro)
server:
servlet:
session:
cookie:
http-only: true
secure: true # En producción, la cookie de sesión debe ir solo por HTTPS
Y sobrescribir localmente (sí, es un compromiso consciente para facilitar el aprendizaje):
# application-local.yml
server:
servlet:
session:
cookie:
secure: false # Localmente en http://localhost, de lo contrario el navegador podría no enviar la cookie
Es importante no confundir el significado: secure=false no es «la configuración correcta», es «la configuración para que te sea cómodo aprender en HTTP». En la vida normal, ejecutas localmente HTTPS (por ejemplo, a través de un proxy inverso con certificado) o aceptas que los parámetros de seguridad en dev son diferentes.
5. SameSite: Strict, Lax, None
Si HttpOnly responde a la pregunta «¿puede JavaScript leer la cookie?», y Secure responde «¿se puede enviar por un canal no seguro?», entonces SameSite responde a otra pregunta: si el navegador adjuntará la cookie en escenarios cross-site.
Y aquí es importante no fusionar dos ejes diferentes. CORS mira el origin (esquema, host y puerto). SameSite vive en la política de cookies del navegador y decide otra cosa: en qué escenarios entre sitios la cookie viajará junto con la solicitud. Por lo tanto, diferentes puertos ya crean un problema CORS, pero no convierten SameSite=None en una configuración automática.
Para nuestro baseline local http://localhost:5173 -> http://localhost:8080, en primer lugar surgen CORS, preflight y la entrega de la cookie en sí. SameSite pasa a primer plano cuando el despliegue se va a una rama explícitamente cross-site/HTTPS. Allí ya se puede configurar CORS perfectamente, permitir credentials, pero el navegador igual no enviará JSESSIONID si la política de cookies lo prohíbe.
Fijemos esto con una comparación comprensible. SameSite tiene tres modos principales:
| SameSite | Idea en palabras muy sencillas | Efecto típico para la session cookie |
|---|---|---|
| Strict | cookie solo si el usuario está «dentro de nuestro sitio» | máxima protección, pero fácilmente rompe escenarios de redirecciones e integraciones externas |
| Lax | la cookie normalmente no se envía en solicitudes cross-site de fondo, pero se envía en navegaciones más «naturales» | a menudo un buen valor por defecto: reduce riesgos CSRF sin romper todo |
| None | la cookie se puede enviar también en solicitudes cross-site | necesario para un esquema explícito cross-site/HTTPS, pero requiere Secure (en navegadores modernos) |
Ahora, el punto más importante por el que vale la pena recordar esta tabla: SameSite no es un «interruptor de CSRF», pero puede reducir los riesgos CSRF. Con Lax o Strict, el navegador envía la cookie con menos frecuencia en escenarios cross-site «sospechosos», y algunos intentos de CSRF se vuelven más difíciles. Pero esto no reemplaza el token CSRF, porque CSRF no es solo un «caso raro», sino un modelo sistémico de navegador + cookie.
Para una API stateful donde el frontend y el backend viven dentro de un mismo site, Lax suele ser suficiente e incluso agradable. Para un esquema explícitamente cross-site/HTTPS, donde la session cookie debe viajar entre diferentes sites, a menudo se necesita None, pero entonces se activa toda una cascada de requisitos: HTTPS, CORS credentials, un bootstrap cuidadoso del token CSRF y disciplina en la configuración. En el desarrollo local con http://localhost:5173 -> http://localhost:8080, esto no significa que debas activar SameSite=None automáticamente: primero verifica si no se rompe todo antes, por CORS, preflight o las propias cookies.
En Spring Boot, normalmente se configura así:
server:
servlet:
session:
cookie:
same-site: LAX
Si pones SameSite=None, casi siempre (y de hecho es obligatorio para los navegadores modernos) necesitas:
server:
servlet:
session:
cookie:
same-site: NONE # Permitir el envío de cookies en solicitudes cross-site (necesario para un origen frontend separado)
secure: true
De lo contrario, el navegador podría decir: «cookie con SameSite=None sin Secure — ni siquiera la almacenaré».
6. Dónde se configuran los atributos de la session cookie
Una trampa muy típica de principiante: buscas SameSite en HttpSecurity, intentas «agregar una bandera en authorizeHttpRequests», buscas filtros extraños en Google y al final lo complicas todo. Pero la verdad es más simple: los atributos de la session cookie son, en la mayoría de los casos, una configuración a nivel del contenedor de servlets / configuración del servidor Spring Boot, no del DSL de Spring Security.
Para nuestra Secure Content Platform API, esto significa que SecurityFilterChain sigue siendo responsable de las reglas de acceso (permitAll, authenticated, etc.), CSRF y CORS, pero los parámetros de JSESSIONID es más conveniente mantenerlos en application.yml y los perfiles. Por ejemplo:
# application.yml
server:
servlet:
session:
cookie:
http-only: true
secure: true
same-site: LAX
Y, para que la «realidad HTTPS» no te moleste durante la ejecución local, creamos un perfil local:
# application-local.yml
server:
servlet:
session:
cookie:
secure: false
Si quieres ver cómo se refleja esto en la respuesta real del servidor, después del inicio de sesión puedes abrir DevTools → pestaña Network → solicitud a /login o a cualquier endpoint donde se cree la sesión por primera vez, y mirar Set-Cookie. Deberías ver algo como:
Set-Cookie: JSESSIONID=...; Path=/; HttpOnly; SameSite=Lax
En modo HTTPS se añadirá Secure.
A veces, para el aprendizaje, es útil tener un pequeño «inspector» de si se crea la sesión y si hay cookie en la solicitud. Por ejemplo:
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DebugSessionController {
@GetMapping("/api/debug/session")
String session(HttpServletRequest request) {
// getSession(false) — no creamos una nueva sesión «por accidente», solo verificamos la existente
// Esto es importante para que el endpoint de diagnóstico no enmascare el problema creando una sesión él mismo
return request.getSession(false) == null ? "no session" : "session exists";
}
}
Esto no es sobre funcionalidad de negocio, sino sobre diagnóstico. Mantén estos endpoints bajo reglas de acceso estrictas (por ejemplo, solo ADMIN), o solo en el perfil local. Y no los conviertas en una «página de estado» pública: la seguridad ama el silencio, no contar historias sobre sí misma a cualquiera que quiera escuchar.
7. Diagnóstico: CORS vs cookie
En las tareas reales, lo más desagradable no es que algo no funcione, sino que no funcione «de manera similar». CORS, cookie, session, CSRF y authorization a menudo dan el síntoma «la solicitud no pasó», pero las causas son diferentes. Si no aprendes a distinguirlas ahora, después (cuando el proyecto se vuelva más complejo) estarás arreglando la capa equivocada. Y eso es como tratar un dolor de muela cambiando la fuente en el IDE: se realiza una acción, pero el sentido es dudoso.
Un modelo mental útil es este: CORS se trata de si el navegador puede dar acceso a JavaScript a la respuesta. Los atributos de las cookies se tratan de si la cookie irá en la solicitud en absoluto. Y esto se puede ver manualmente: abre Network, mira Request Headers y Response Headers. El hecho de que la solicitud sea cross-origin no significa que la haya roto específicamente SameSite: muy a menudo el problema está un nivel antes: en el preflight, los encabezados CORS, o en que el cliente no envió credentials en absoluto.
Un pequeño diagrama de flujo de diagnóstico (simplificado, pero funcional para un principiante):
flowchart TD
A[La solicitud desde el navegador no funciona] --> B{"¿Hay respuesta del servidor en Network?"}
B -->|No, el navegador bloquea| C[Mirar CORS / preflight / allowed headers]
B -->|Sí, hay respuesta| D{"¿La solicitud tiene Cookie: JSESSIONID?"}
D -->|No| E[Verificar SameSite/Secure + fetch credentials + dominios]
D -->|Sí| F{"¿Código de respuesta 401/403?"}
F -->|401| G[No hay autenticación: sesión no reconocida o flujo de logout/login]
F -->|403| H[Autenticación presente, pero CSRF/role/authority prohibieron la acción]
Aquí no es necesario memorizar todo a la perfección. Lo principal es la idea: verificamos dónde «se rompe la cadena»: el navegador no permitió ejecutar la solicitud, la cookie no se fue, el servidor no restauró la sesión, CSRF rechazó la solicitud, o la autorización denegó el acceso.
Y otra cosa importante: Postman y curl no se comportan como un navegador. No aplican SameSite de manera tan estricta (o no lo aplican en absoluto), no hacen preflight y no «ocultan» la respuesta de JavaScript. Por lo tanto, la frase «en Postman funciona» en el contexto de la integración con el navegador significa solo una cosa: «el servidor está vivo, el endpoint existe». Y luego comienza la política real del navegador.
8. Errores típicos al trabajar con cookies
Error n.º 1: confundir «la cookie no se lee» con «la cookie no se envía».
HttpOnly prohíbe a JavaScript leer la cookie, pero no impide en absoluto que el navegador la envíe al servidor. Por lo tanto, HttpOnly no «desactiva CSRF» ni hace que la cookie sea «automáticamente segura». Simplemente elimina uno de los canales de fuga: a través de JS.
Error n.º 2: activar Secure=true en HTTP local y luego pasar horas arreglando la «sesión rota».
Si pruebas en http://localhost y la cookie está marcada como Secure, el navegador puede dejar de guardarla/enviarla. Como resultado, ves un «inicio de sesión exitoso», pero la siguiente solicitud vuelve a ser anonymous. Esto parece un error de Spring Security, pero en realidad es el comportamiento esperado del navegador.
Error n.º 3: poner SameSite=None, olvidarse de Secure y sorprenderse de que la cookie «desapareció en algún lugar».
Los navegadores modernos requieren Secure para SameSite=None. Si no está, pueden simplemente no aceptar la cookie. Resultado: ninguna sesión, ningún SecurityContext, y de nuevo la sensación de «el framework no funciona».
Error n.º 4: intentar solucionar problemas cross-origin solo con la configuración CORS.
CORS se encarga de que la página tenga acceso a la respuesta, pero no garantiza el envío de la cookie. Si SameSite prohíbe el envío de JSESSIONID, tu API verá la solicitud como anonymous, incluso si todos los encabezados Access-Control-Allow-* son perfectos.
Error n.º 5: hacer que la session cookie sea «accesible desde JavaScript» sin una razón sólida.
A veces el desarrollador pone HttpOnly=false porque «así es más fácil depurar». Este es un mal hábito: amplías la superficie de ataque. Si aparece XSS, la cookie se puede robar más fácilmente. Para JSESSIONID, casi siempre es razonable mantener HttpOnly=true, y los «tokens explícitos» necesarios para el cliente (por ejemplo, el bootstrap CSRF) resolverlos con otros mecanismos, sin convertir la sesión en un secreto accesible desde JS.
GO TO FULL VERSION