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.com comprometido, aún se considera “same site” con app.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), Lax aú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.

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 cookie XSRF-TOKEN debe ser legible desde JS (document.cookie), por lo que no se puede establecer HttpOnly. 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=Lax en 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=None porque 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 de spring-security-test que 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.

web-securitycsrfspring-securitycookiespring-boot