
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,HEADoPOST. Content-Typeentreapplication/x-www-form-urlencoded,multipart/form-dataotext/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á
OPTIONSantes 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*conallowCredentials=true.setAllowedOriginPatterns(List<String>): patrones con comodín. Permitehttps://*.demo.localcon 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("*"))conallowCredentials=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 enfetchcross-origin. Default moderno; útil para apps en el mismo dominio.SameSite=None: viaja en todas las peticiones cross-site. RequiereSecure(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
@Ordercon valor mayor que el filtro CORS. Access-Control-Allow-Origin: *en API con cookies: roto.- No exponer headers personalizados: la SPA puede ver
Authorizationy headers estándar, pero noX-Total-Countsalvo que se exponga.
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.