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.

Where a preflight dies in the Spring pipeline Six stages of the Spring request pipeline in order, each with a note on what it does to a CORS preflight. CorsFilter can answer it early, the authorization filter rejects it with 401 if CorsFilter is not installed, and the handler mapping that reads CrossOrigin runs two stages too late. Stage, in execution order What it does to the preflight Servlet container, filter chain entry nothing is routed yet, no handler is known CorsFilter, installed by http.cors(...) answers it 200 with the full header set CsrfFilter skips it, OPTIONS is a read-only method AuthorizationFilter 401: an anonymous call to a guarded path DispatcherServlet and its handler mapping the first place @CrossOrigin is even read @RestController method a preflight never gets here, by design Two stages separate the rejection from the annotation meant to prevent it

Prerequisite state

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.

Wildcard origins versus origin patterns under credentials The left panel shows setAllowedOrigins with an asterisk combined with allowCredentials true, which throws an IllegalArgumentException on the first preflight. The right panel shows setAllowedOriginPatterns, which matches at request time and echoes the concrete origin alongside Vary Origin. allowedOrigins with a wildcard api.setAllowedOrigins( List.of("*")); api.setAllowCredentials(true); IllegalArgumentException thrown on the very first preflight, surfacing as a 500 with no CORS headers the browser reports it as a missing Access-Control-Allow-Origin header allowedOriginPatterns instead api.setAllowedOriginPatterns( List.of("https://*.helios-pay.eu")); api.setAllowCredentials(true); The pattern is matched at request time and the concrete origin echoed: https://console.helios-pay.eu credentials stay valid and Vary: Origin is emitted so caches partition correctly Patterns are for genuinely open-ended origin sets — enumerate them whenever you can

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
Three Spring CORS surfaces compared A three-row matrix. The CrossOrigin annotation and addCorsMappings are both evaluated in the handler mapping and neither precedes authentication, while a CorsConfigurationSource bean wired through http.cors runs in the security filter chain and covers every path. Configuration surface Where it is evaluated Runs before authentication? What it is good for @CrossOrigin handler mapping, per handler no — the chain has already replied narrowing one endpoint in an unsecured service WebMvcConfigurer #addCorsMappings handler mapping, global no — same stage, same limitation MVC-wide defaults when security is not on the path CorsConfigurationSource + http.cors(...) CorsFilter, in the security chain yes — and it covers non-MVC paths too every service that has Spring Security on it The bottom row is the only surface that exists before the request is routed

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.