Spring Boot CORS Without Security Filter Conflicts
A Spring Boot 3 service at https://gateway.helios-pay.eu has @CrossOrigin on the controller. The browser at https://console.helios-pay.eu still reports:
Access to XMLHttpRequest at 'https://gateway.helios-pay.eu/v1/payouts' from
origin 'https://console.helios-pay.eu' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Open the Network panel and the OPTIONS row shows status 401, sometimes 403. The controller annotation is present, the origin string is right, and nothing in the application log mentions CORS at all.
Root cause
Spring has two completely separate places where a CORS policy can live, and only one of them runs early enough to matter. @CrossOrigin and WebMvcConfigurer#addCorsMappings are consumed by the handler mapping inside DispatcherServlet — which means they can only take effect after routing has resolved the request to a handler. Spring Security is a servlet filter chain, and it runs before the dispatcher is invoked at all.
A CORS-preflight fetch is an OPTIONS request with no cookies, no Authorization header and no body. To an authorization filter it is an anonymous request for a protected path, so the filter returns 401 and the chain stops. DispatcherServlet is never entered, the handler mapping never runs, and the annotation you wrote is never read. The fix is to move the CORS decision into the filter chain itself, where Spring Security places CorsFilter ahead of the CSRF and authentication filters specifically so preflights can be answered before any credential check happens. This page is the Spring walkthrough for Framework CORS Middleware Configuration, which describes the same “earliest component wins” rule in other stacks.
Prerequisite state
- Spring Boot 3.x with
spring-boot-starter-webandspring-boot-starter-securityon the classpath. - A
SecurityFilterChainbean you own — if you are still relying on the auto-configured default, create one first, because you cannot enablehttp.cors(...)without it. - The front-end origin written out exactly, scheme included, with no trailing slash.
curlavailable, so you can inspect theOPTIONSstatus line directly instead of guessing from the console message.
Step 1 — Stop relying on the annotation
Delete or demote any @CrossOrigin that is currently the only CORS configuration in the service. Leaving it in place while adding the filter-level policy is not harmful, but it hides the real source of truth from the next person to read the code.
// Before: the only CORS configuration in the service, and it never runs.
@CrossOrigin(origins = "https://console.helios-pay.eu", allowCredentials = "true")
@RestController
@RequestMapping("/v1/payouts")
class PayoutController { /* ... */ }
Step 2 — Declare a CorsConfigurationSource bean
This bean is the single definition of the policy. UrlBasedCorsConfigurationSource maps it onto path patterns, so different route families can carry different rules.
package eu.heliospay.gateway.config;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration api = new CorsConfiguration();
api.setAllowedOrigins(List.of(
"https://console.helios-pay.eu",
"https://ops.helios-pay.eu"));
api.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
api.setAllowedHeaders(List.of("Authorization", "Content-Type", "Idempotency-Key"));
api.setExposedHeaders(List.of("X-Request-Id", "X-RateLimit-Remaining"));
api.setAllowCredentials(true);
api.setMaxAge(600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/v1/**", api);
return source;
}
}
The bean name matters. http.cors(Customizer.withDefaults()) looks up a CorsConfigurationSource bean by the conventional name corsConfigurationSource; naming the method anything else leaves the filter installed with no configuration and the preflight fails exactly as before.
Step 3 — Enable CORS in the security filter chain
One line installs CorsFilter at the right position — after the context and header filters, before CSRF and authentication.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // token-authenticated API; keep CSRF for cookie sessions
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
return http.build();
}
Note what is not here: no requestMatchers(HttpMethod.OPTIONS, "/**").permitAll(). That line is the popular workaround, and it does make the 401 go away — by letting every unauthenticated OPTIONS request through to the dispatcher on every endpoint in the service. It also leaves the actual POST response without CORS headers, because permitting a method is not the same as emitting a policy. The filter answers both exchanges from one definition.
For a WebFlux service the shape is the same but the types differ: register a CorsWebFilter built from a UrlBasedCorsConfigurationSource, or enable ServerHttpSecurity#cors.
Step 4 — Wildcards and credentials
Spring enforces the Fetch Standard’s incompatibility between Access-Control-Allow-Origin: * and credentials, and it does so loudly. Setting allowedOrigins to "*" while allowCredentials is true throws on the first request that reaches the filter:
java.lang.IllegalArgumentException: When allowCredentials is true,
allowedOrigins cannot contain the special value "*" since that cannot be
set on the "Access-Control-Allow-Origin" response header. To allow
credentials to a set of origins, list them explicitly or consider using
"allowedOriginPatterns" instead.
allowedOriginPatterns is matched at request time and the concrete origin is what gets echoed, so credentials remain valid.
Pin the scheme and keep the wildcard to a single label. https://*.helios-pay.eu is defensible; a pattern that allows an arbitrary suffix is the mistake catalogued in Wildcard CORS Risks and Safe Origin Allowlisting.
Choosing between the three configuration surfaces
| Configuration surface | Where it is evaluated | Sees the preflight before authentication | Covers non-MVC paths | Use it for |
|---|---|---|---|---|
@CrossOrigin |
handler mapping, per handler | no | no | narrowing one endpoint in a service with no security filter chain |
WebMvcConfigurer#addCorsMappings |
handler mapping, global | no | no | MVC-wide defaults when Spring Security is absent |
CorsConfigurationSource + http.cors(...) |
CorsFilter, inside the security chain |
yes | yes | any service that has Spring Security on the classpath |
Verification
Send the preflight from the shell and read the status line before anything else. A 200 means CorsFilter handled it; a 401 means the filter is not installed or the bean was not picked up.
curl -sS -i -X OPTIONS https://gateway.helios-pay.eu/v1/payouts \
-H 'Origin: https://console.helios-pay.eu' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: authorization, content-type, idempotency-key'
Security boundary note
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() deserves one more warning, because it appears in almost every answer to this problem. It weakens the authorization rules of every endpoint in the service in order to fix a header, and it leaves the actual request response uncovered, so teams typically add @CrossOrigin back on top and end up with two policies that disagree. Keep the endpoint matchers describing authorization only and let CorsFilter describe CORS. Equally, allowCredentials(true) should be scoped to the route family that genuinely uses cookies or bearer tokens; registering it on /** extends a credentialed grant to health checks and metrics endpoints that never needed one. The reasoning behind that separation is set out in Credential Sharing & Security Boundaries in CORS.
Common Mistakes
| Mistake | Technical impact | Fix |
|---|---|---|
Relying on @CrossOrigin with Spring Security enabled |
The security chain answers the preflight with 401 before the handler mapping reads the annotation |
Declare a CorsConfigurationSource bean and call http.cors(Customizer.withDefaults()) |
Naming the bean method something other than corsConfigurationSource |
CorsFilter is installed with no configuration and behaves as if CORS were never enabled |
Keep the conventional bean name, or pass the source explicitly to the cors customiser |
setAllowedOrigins(List.of("*")) together with setAllowCredentials(true) |
IllegalArgumentException on the first preflight, surfacing as a 500 with no headers |
Enumerate origins, or switch to setAllowedOriginPatterns with a narrow pattern |
Permitting all OPTIONS requests to silence the 401 |
Unauthenticated OPTIONS reaches every endpoint, and the real response still has no CORS headers |
Remove the matcher and let CorsFilter answer preflights from the shared policy |
FAQ
Why does @CrossOrigin stop working after I add Spring Security?
Because the annotation is read by the handler mapping inside DispatcherServlet, and the security filter chain runs earlier, in the servlet container. A preflight carries no session cookie and no Authorization header, so the authorization filter rejects it before the dispatcher ever selects a handler. The annotation is not ignored — it is simply never reached. Declaring a CorsConfigurationSource bean and calling http.cors moves the CORS decision into the filter chain, ahead of authentication.
Is permitting all OPTIONS requests a valid alternative?
It removes the 401 but it does not finish the job. Permitting OPTIONS lets the preflight reach the dispatcher, where the handler mapping can answer it, but it also exposes every endpoint to unauthenticated OPTIONS traffic and it does nothing for the actual GET or POST response, which still needs its own Access-Control-Allow-Origin. The CorsConfigurationSource bean covers both exchanges with one definition and keeps the endpoint matchers free of a CORS-shaped exception.
When should I use allowedOriginPatterns instead of allowedOrigins?
Use allowedOrigins whenever you can enumerate the origins, because an exact list is the strongest check available. Reach for allowedOriginPatterns only when the set is genuinely open-ended, such as per-tenant subdomains provisioned at runtime. Patterns are also the only way to combine a wildcard with credentials, because allowedOrigins containing an asterisk throws an IllegalArgumentException as soon as allowCredentials is true. Keep the pattern narrow: pin the scheme and restrict the wildcard to a single label, following the anchoring guidance in Dynamic Origin Validation Patterns.