CORS¶
Browsers enforce the same-origin policy: a page served from one origin may not freely call an API on another origin. Cross-Origin Resource Sharing (CORS) relaxes this through response headers such as Access-Control-Allow-Origin. For requests that are not "simple", the browser first sends a preflight OPTIONS request carrying Origin and Access-Control-Request-Method, and only proceeds when the server answers with matching Access-Control-Allow-* headers.
WebFlux offers three levels of CORS configuration:
- a
CorsWebFilterbean — global rules applied in theWebFilterchain, before handler mapping WebFluxConfigurer#addCorsMappings— global rules applied at the handler-mapping level@CrossOrigin— fine-grained rules on a controller class or handler method
The examples referenced here live in this repository: cors (manual configuration) and boot-cors (Spring Boot customization).
Manual configuration with CorsWebFilter¶
CorsWebFilter is a WebFilter that evaluates a CorsConfigurationSource for every request. Pair it with UrlBasedCorsConfigurationSource to map path patterns to CORS rules:
@Configuration
@EnableWebFlux
public class WebConfig {
@Bean
public CorsWebFilter corsWebFilter() {
var config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://allowed-origin.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
Both WebHttpHandlerBuilder and Spring Boot pick up WebFilter beans automatically, so no further registration is needed. Requests from any other origin are rejected with 403 FORBIDDEN; requests without an Origin header pass through untouched.
Alternative: WebFluxConfigurer¶
If the application already implements WebFluxConfigurer, CORS mappings can be declared through addCorsMappings instead of a filter bean:
public class CorsWebFluxConfigurer implements WebFluxConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("https://allowed-origin.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
These mappings are applied by the handler mappings rather than the filter chain. In the cors example this class is deliberately not registered as a Spring configuration — annotate it with @Configuration (or @Import it from WebConfig) to activate it in place of the CorsWebFilter bean.
Spring Boot customization¶
Spring Boot 4 has no spring.webflux.cors.* properties; CORS is customized with beans and annotations. A CorsWebFilter bean is detected automatically, and the rules can be scoped to specific paths:
@Configuration(proxyBeanMethods = false)
public class CorsConfig {
@Bean
public CorsWebFilter corsWebFilter() {
var config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://allowed-origin.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/posts/**", config);
return new CorsWebFilter(source);
}
}
For individual handlers, @CrossOrigin declares the allowed origins directly on the controller method:
@RestController
@RequestMapping("/hello")
public class HelloController {
@GetMapping
@CrossOrigin(origins = "https://hello-origin.com")
public Mono<String> hello() {
return Mono.just("Hello CORS");
}
}
Note
Keep filter-level and annotation-level rules on distinct paths when demonstrating both. In boot-cors the filter covers /posts/** while @CrossOrigin applies to /hello, so each mechanism can be observed independently.
Testing with WebTestClient¶
A preflight test asserts the negotiated headers:
this.rest
.options().uri("/posts")
.header(HttpHeaders.ORIGIN, "https://allowed-origin.com")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
.exchange()
.expectStatus().isOk()
.expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://allowed-origin.com")
.expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")
.expectHeader().valueEquals(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "3600");
A disallowed origin is rejected for both preflight and actual requests:
this.rest
.get().uri("/posts")
.header(HttpHeaders.ORIGIN, "https://disallowed-origin.com")
.exchange()
.expectStatus().isForbidden();
Note
As of Spring Framework 7.1, the CORS processor validates the Origin against the request URI's scheme, host, and port. When binding WebTestClient to an application context, set a base URL such as .baseUrl("http://localhost:8080") — otherwise the mock request has no scheme or host and every CORS request is rejected as a malformed origin. Tests against a running server (@SpringBootTest with RANDOM_PORT) are not affected.