CSRF y SameSite Cookie: Mecanismos de defensa en Spring Security
1. Descripción general
CSRF (Cross-Site Request Forgery) es una de las vulnerabilidades web clásicas que sigue causando confusión: muchas personas piensan que simplemente activar SameSite=Strict es suficiente y que no necesitan CSRF token. Este artículo explica el mecanismo de ataque CSRF, el papel real de las cookies SameSite, y cómo configurar correctamente la protección CSRF en Spring Security. Después de leerlo, sabrás cuándo necesitas CSRF token, cuándo SameSite es suficiente, y cómo escribir tests para verificarlo.
2. Cómo funciona CSRF
Supongamos que tienes un PaymentService con un endpoint de transferencia de dinero, autenticado mediante cookies de sesión:
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/transfer")
public ResponseEntity<Void> transfer(@RequestParam String toAccount,
@RequestParam BigDecimal amount,
HttpServletRequest request) {
String customerId = (String) request.getSession().getAttribute("customerId");
paymentService.transfer(customerId, toAccount, amount);
return ResponseEntity.ok().build();
}
}
Si un customer ya ha iniciado sesión (el navegador tiene la cookie de sesión), una página maliciosa en evil.com solo necesita incrustar este formulario:
<form action="https://bank.example.com/api/payments/transfer" method="POST">
<input type="hidden" name="toAccount" value="attacker-account">
<input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit();</script>
Cuando el customer abre evil.com, el navegador adjunta automáticamente la cookie de bank.example.com a la solicitud, porque el navegador solo se preocupa por el dominio de destino, no por de dónde proviene la solicitud. El servidor recibe una solicitud válida en términos de sesión y ejecuta la transferencia. Esa es la esencia del CSRF: aprovechar que el navegador envía automáticamente las credenciales (cookie) junto con la solicitud, sin importar de qué página se originó.
Nota: CSRF solo es peligroso con mecanismos de autenticación “ambient” como cookies. Si usas un encabezado
Authorization: Bearer <token>y el cliente debe establecer manualmente ese encabezado (JavaScript no puede hacerlo cross-site debido a CORS), CSRF clásico no es aplicable.
3. Atributo SameSite de cookies
SameSite es una bandera en la cookie que le indica al navegador: ¿enviar esta cookie con solicitudes cross-site o no? Hay 3 valores:
| Valor | Comportamiento |
|---|---|
Strict |
La cookie solo se envía cuando la solicitud es del mismo sitio (first-party navigation). No se envía ni siquiera cuando el usuario hace clic en un enlace desde otro sitio. |
Lax |
La cookie se envía con navegación top-level GET (usuario hace clic en un enlace), pero no se envía con POST/subrequests desde otro sitio. |
None |
La cookie siempre se envía cross-site, requiere obligatoriamente Secure. |
Configuración en Spring Boot a través de application.yml:
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
Con same-site: lax, la solicitud CSRF en el ejemplo anterior (formulario POST auto-enviado desde evil.com) no llevará la cookie de sesión — los navegadores modernos lo bloquean. Esta es la razón por la que Lax se convirtió en el valor por defecto de Chrome desde 2020 y bloquea la mayoría de ataques CSRF de tipo form-POST.
3.1. Por qué SameSite no es suficiente
SameSite no es una solución completa por varias razones:
- Subdomain takeover: si tu sitio tiene
blog.example.comcomprometido, aún se considera “same site” conapp.example.com— la cookie se envía igual. - Navegadores antiguos: algunos navegadores/versiones no soportan completamente
SameSite, lo tratan como si no existiera la bandera y regresan al envío de cookies como antes. - Solicitudes GET con efecto secundario: si tu endpoint permite accidentalmente que GET realice acciones que escriben datos (anti-pattern pero aún existe),
Laxaún permite que top-level navigation GET lleve la cookie.
// Anti-pattern: GET con efecto secundario, Lax no puede bloquearlo
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancel(id); // peligroso si se expone a través de <img src="...">
return ResponseEntity.ok().build();
}
Por lo tanto, SameSite debe considerarse como una capa de defensa adicional (defense in depth), no como un reemplazo para CSRF token.
4. CSRF token: mecanismo de protección principal
El token CSRF funciona según el principio de double-submit / synchronizer token: el servidor genera un token aleatorio, lo adjunta a la sesión o a una cookie separada, y requiere que cada solicitud que modifique datos (POST/PUT/DELETE) incluya este token en el cuerpo o en un encabezado. Un atacante en evil.com no puede leer el token (porque la same-origin policy bloquea la lectura de respuestas/cookies de otro dominio), por lo que no puede falsificar una solicitud válida.
4.1. Añadir Spring Security
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Por defecto, Spring Security 6.x habilita la protección CSRF para todas las solicitudes que no sean GET/HEAD/OPTIONS/TRACE.
4.2. Configurar CSRF con cookie-based token repository
Para SPA (React, Vue) que llama a la API a través de AJAX, usa CookieCsrfTokenRepository para que JS lea el token de la cookie y lo adjunte al encabezado:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/payments/**").authenticated()
.anyRequest().permitAll()
);
return http.build();
}
}
Explicación:
withHttpOnlyFalse(): la cookieXSRF-TOKENdebe ser legible desde JS (document.cookie), por lo que no se puede establecerHttpOnly. Este es un trade-off intencional — este token no es tan sensible como la cookie de sesión, perderlo no es suficiente para atacar si el atacante no puede leerlo cross-site.CsrfTokenRequestAttributeHandler: maneja la carga perezosa del token, evitando tener que renderizar el token en la primera solicitud (optimización para SPA).
En el lado del cliente, cada solicitud debe leer la cookie XSRF-TOKEN y establecer el encabezado X-XSRF-TOKEN:
function getCsrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
}
fetch('/api/payments/transfer', {
method: 'POST',
headers: { 'X-XSRF-TOKEN': getCsrfToken(), 'Content-Type': 'application/json' },
body: JSON.stringify({ toAccount: 'ACC123', amount: 100 })
});
Un atacante en evil.com no puede leer la cookie XSRF-TOKEN de bank.example.com (same-origin policy), por lo que no puede establecer el encabezado correcto. Un formulario auto-enviado no puede establecer encabezados personalizados, por lo que la solicitud se bloquea incluso si SameSite=None.
4.3. Configuración con formulario tradicional (server-side render)
Si usas Thymeleaf, el token se renderiza directamente en el formulario:
<form th:action="@{/api/orders}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<input type="text" name="productName"/>
<button type="submit">Crear Orden</button>
</form>
Spring MVC inyecta automáticamente _csrf en el modelo gracias a CsrfRequestDataValueProcessor, sin necesidad de código adicional.
5. Combinar SameSite y CSRF token: defense in depth
Estas dos capas abordan problemas diferentes, por lo que deben usarse juntas:
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
return http.build();
}
SameSite=Laxen la cookie de sesión bloquea la mayoría de solicitudes cross-site directamente en el navegador, antes de que la solicitud llegue al servidor.- El token CSRF bloquea los casos que SameSite no cubre (navegadores antiguos, compromiso de subdomain, o usar intencionalmente
SameSite=Noneporque necesitas soportar incrustación cross-site).
6. Escribir tests para verificar la protección CSRF
Usa MockMvc con spring-security-test para verificar que los endpoints se bloquean cuando falta el token:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerCsrfTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldRejectPostWithoutCsrfToken() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productName\":\"Laptop\"}"))
.andExpect(status().isForbidden());
}
@Test
void shouldAcceptPostWithValidCsrfToken() throws Exception {
mockMvc.perform(post("/api/orders")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productName\":\"Laptop\"}"))
.andExpect(status().isOk());
}
}
Usa AssertJ para la lógica de negocio no relacionada con la capa HTTP, por ejemplo, verificar que el token se genere aleatoriamente en cada sesión:
@Test
void csrfTokenShouldBeUniquePerSession() {
CsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
CsrfToken token1 = repository.generateToken(new MockHttpServletRequest());
CsrfToken token2 = repository.generateToken(new MockHttpServletRequest());
assertThat(token1.getToken()).isNotEqualTo(token2.getToken());
}
- El primer test confirma que una solicitud POST sin token recibe
403 Forbidden— el comportamiento predeterminado de Spring Security. with(csrf())es un helper despring-security-testque adjunta automáticamente un token válido a la solicitud simulada.- El tercer test asegura que el token no se reutilice/adivine entre sesiones.
7. Errores comunes y cuándo no necesitas CSRF token
Error 1 — Desactivar CSRF globalmente para la API “para facilitar las pruebas”:
// Anti-pattern: no hagas esto en producción
http.csrf(csrf -> csrf.disable());
Solo es razonable si tu API es completamente stateless — autentica mediante JWT en el encabezado Authorization, sin usar cookies para autenticar. En ese caso no hay “credenciales ambiente” que un atacante pueda explotar.
Error 2 — Usar SameSite=None sin entender las consecuencias:
server:
servlet:
session:
cookie:
same-site: none # necesario para iframe cross-site, pero reabre riesgos de CSRF
secure: true
Si te ves obligado a usar None (por ejemplo, incrustar la app en un iframe de terceros), debes tener CSRF token, no puedes omitirlo.
Error 3 — Aplicar CSRF a solicitudes GET con efecto secundario:
No diseñes GET para eliminar/actualizar datos. El filtro CSRF de Spring Security por defecto no protege GET (porque GET se considera un método seguro según RFC 7231), así que si violas el principio REST, abrirás tu propio agujero de seguridad.
Nota: Si tu backend sirve tanto web app (basada en cookies) como API móvil/de terceros (basada en tokens) en el mismo dominio, separa la configuración de CSRF por
RequestMatcher— habilita CSRF solo para rutas que usan cookies, desactívalo para rutas que usan Bearer token puro.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/mobile/**") // autenticado con JWT, no cookies
);
return http.build();
}
8. Conclusión
El token CSRF y la cookie SameSite resuelven dos aspectos diferentes del mismo problema: prevenir que el navegador envíe automáticamente credenciales con solicitudes maliciosas desde otro sitio. SameSite es una capa de defensa a nivel de navegador, simple de activar pero no completa. El token CSRF es una capa de defensa a nivel de aplicación, más confiable pero requiere código adicional tanto en servidor como en cliente. Para sistemas que usan cookies de sesión, usa ambos simultáneamente; para API stateless que se autentican mediante Bearer token, puedes omitir el filtro CSRF pero mantener SameSite para prevenir otros riesgos relacionados con cookies.