CORS avanzado en Spring Security: SPA, SameSite y preflight

Avanzado
Spring Security
Spring Security
Actualizado: 22/04/2026

Diagrama: CORS avanzado para SPA cross-domain

CORS (Cross-Origin Resource Sharing) es la fuente número uno de errores misteriosos cuando una SPA llama a un backend Spring desde un dominio distinto. La configuración por defecto de Spring Security bloquea cualquier petición cross-origin, lo que resulta en respuestas vacías y mensajes opacos en la consola del navegador.

Esta lección cubre la configuración correcta para los escenarios habituales y los detalles que suelen pasarse por alto.

Recordatorio: same-origin policy

El navegador considera mismo origen la combinación exacta de protocolo + host + puerto. https://app.demo.local:443 y https://api.demo.local:443 son orígenes distintos.

Cuando JavaScript hace fetch("https://api.demo.local/pedidos") desde https://app.demo.local, el navegador exige al servidor que declare explícitamente que acepta peticiones desde ese origen. Ese mecanismo es CORS.

sequenceDiagram
    participant SPA as SPA (app.demo.local)
    participant API as Backend (api.demo.local)
    SPA->>API: OPTIONS /pedidos<br>Origin: https://app.demo.local<br>Access-Control-Request-Method: POST
    API->>SPA: 204 No Content<br>Access-Control-Allow-Origin: https://app.demo.local<br>Access-Control-Allow-Methods: POST<br>Access-Control-Allow-Headers: Authorization
    SPA->>API: POST /pedidos<br>Authorization: Bearer ...
    API->>SPA: 201 Created

El preflight (OPTIONS) es la primera petición; solo si el servidor responde con las cabeceras correctas, el navegador envía la real.

Cuándo se dispara el preflight

El navegador omite el preflight para peticiones simples:

  • Métodos GET, HEAD o POST.
  • Content-Type entre application/x-www-form-urlencoded, multipart/form-data o text/plain.
  • Sin cabeceras custom.

Cualquier desviación (PUT, DELETE, JSON, Authorization) dispara el preflight automáticamente.

En APIs REST modernas, prácticamente todas las peticiones disparan preflight. Asume que siempre habrá OPTIONS antes y configura el servidor para responderlas rápido.

Configuración estándar en Spring Security

@Configuration
public class CorsConfig {

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "https://app.demo.local",
            "https://admin.demo.local"
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
        config.setExposedHeaders(List.of("X-Total-Count", "X-Pagination"));
        config.setAllowCredentials(true);
        config.setMaxAge(Duration.ofHours(1));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

Y la SecurityFilterChain lo referencia:

@Bean
SecurityFilterChain http(HttpSecurity http) throws Exception {
    http
        .cors(Customizer.withDefaults())
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .csrf(csrf -> csrf.disable());
    return http.build();
}

Customizer.withDefaults() busca el bean CorsConfigurationSource y lo aplica.

Allowed Origins vs Origin Patterns

Hay dos métodos:

  • setAllowedOrigins(List<String>): lista exacta. Imposible usar * con allowCredentials=true.
  • setAllowedOriginPatterns(List<String>): patrones con comodín. Permite https://*.demo.local con credentials.
config.setAllowedOriginPatterns(List.of(
    "https://*.demo.local",
    "https://localhost:*"
));

AllowedOriginPatterns es la opción moderna para staging y entornos donde el subdominio del cliente no se conoce de antemano.

Nunca uses setAllowedOrigins(List.of("*")) con allowCredentials=true. El navegador rechaza la combinación con un error explícito.

Cookies SameSite y CORS

Cuando una SPA y la API están en dominios distintos pero subdominios del mismo padre (app.demo.local y api.demo.local), las cookies de sesión necesitan SameSite=None; Secure para viajar.

server:
  servlet:
    session:
      cookie:
        same-site: none
        secure: true
        http-only: true
  • SameSite=Strict: la cookie no viaja en peticiones cross-site, ni siquiera con clic en enlace. Demasiado estricto para SPAs cross-domain.
  • SameSite=Lax: viaja en navegaciones top-level (clic en enlace), no en fetch cross-origin. Default moderno; útil para apps en el mismo dominio.
  • SameSite=None: viaja en todas las peticiones cross-site. Requiere Secure (HTTPS obligatorio).

Combinada con CORS:

fetch("https://api.demo.local/pedidos", {
    method: "GET",
    credentials: "include"   // imprescindible para enviar la cookie
});

Sin credentials: "include", el navegador no envía cookies aunque la cabecera CORS lo permita.

CSRF en APIs con SPA cross-domain

Si la SPA está en otro dominio y usa cookies, mantén CSRF activo y usa la estrategia CookieCsrfTokenRepository con doble submit:

.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))

La SPA lee la cookie XSRF-TOKEN y la envía en el header X-XSRF-TOKEN. Spring valida la coincidencia.

CSRF se desactiva solo si la API es 100 % stateless (JWT en header). Con cookies, siempre activo.

Preflight con autenticación

Un error frecuente: la SecurityFilterChain autentica el preflight y devuelve 401, lo que el navegador interpreta como "CORS bloqueado".

Spring Security gestiona el OPTIONS automáticamente cuando configuras CORS, pero si tienes filtros custom, asegúrate de dejar pasar OPTIONS sin autenticar:

.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
    .anyRequest().authenticated())

Vary: Origin

El servidor debe incluir Vary: Origin en las respuestas. Sin ella, los proxies CDN cachean la respuesta de un cliente y la sirven a otro con Origin distinto, rompiendo CORS.

Spring lo añade automáticamente cuando hay configuración CORS, pero comprueba que no se elimine en tu reverse proxy.

Vary: Origin, Access-Control-Request-Headers

CORS con OAuth2 Login

Cuando el flujo OAuth2 redirige al user a un IdP externo y vuelve, el callback (/login/oauth2/code/{provider}) no es cross-origin (es navegación top-level). Pero las llamadas posteriores de la SPA a /api/** sí lo son.

Configuración mínima para SPA + OAuth2:

@Bean
SecurityFilterChain spa(HttpSecurity http) throws Exception {
    http
        .cors(Customizer.withDefaults())
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2Login(Customizer.withDefaults())
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .ignoringRequestMatchers("/login/oauth2/code/*"));
    return http.build();
}

ignoringRequestMatchers excluye el callback OAuth2 de CSRF (el IdP no envía el token CSRF).

Diagnóstico de errores CORS

Los errores típicos en consola y su causa:

| Error en consola | Causa probable | |---|---| | No 'Access-Control-Allow-Origin' | El servidor no responde la cabecera. CORS no está configurado o el filtro está antes. | | Allow-Origin doesn't match | Origen no incluido en setAllowedOrigins. | | Credentials not supported | Combinaste * con allowCredentials=true. | | Method not allowed | Falta el método en setAllowedMethods. | | Header not allowed | Falta el header en setAllowedHeaders. | | Preflight 401 | El preflight requiere autenticación. Excluye OPTIONS. | | Petición exitosa sin respuesta visible | setExposedHeaders no incluye los headers que la SPA quiere leer. |

Performance: cache del preflight

Un preflight por cada fetch añade latencia. La cabecera Access-Control-Max-Age indica al navegador cuánto cachear el resultado del preflight.

config.setMaxAge(Duration.ofHours(1));

Chrome cachea hasta 2 horas, Firefox hasta 24. Valores más altos son ignorados.

Errores frecuentes

  • Configurar CORS solo a nivel @CrossOrigin: la anotación funciona en controladores pero no ante peticiones que Spring Security rechaza antes (401 sin CORS). Configura siempre el bean.
  • Filtros custom antes de CorsFilter: si un filtro custom rechaza el OPTIONS, CORS no se aplica. Usa @Order con valor mayor que el filtro CORS.
  • Access-Control-Allow-Origin: * en API con cookies: roto.
  • No exponer headers personalizados: la SPA puede ver Authorization y headers estándar, pero no X-Total-Count salvo que se exponga.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Security es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Security

Explora más contenido relacionado con Spring Security y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Configurar CORS en Spring Security con CorsConfigurationSource, manejar preflight requests, combinar CORS con cookies SameSite y diagnosticar errores comunes en SPAs.