Why Spring Cloud Gateway Returns Duplicate CORS Headers and How to Fix It
This article explains why Spring Cloud Gateway may emit duplicate Access-Control-Allow-Origin and Vary headers during CORS handling, analyzes the internal processing flow, and provides two practical solutions—using DedupeResponseHeader configuration or a custom GlobalFilter—to eliminate the duplication.
In Spring Cloud projects with front‑end and back‑end separation, developers often encounter two CORS scenarios: the front‑end accesses micro‑service APIs directly, and the front‑end accesses the Spring Cloud Gateway.
When the front‑end runs a local HttpServer and calls a back‑end service without any configuration, the browser blocks the request due to CORS, so a global CORS filter is usually added:
@Bean
public CorsFilter corsFilter() {
logger.debug("CORS限制打开");
CorsConfiguration config = new CorsConfiguration();
// Only in development environment set to *
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config);
return new CorsFilter(configSource);
}For the gateway case, the following configuration is added to application.yml:
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "*"
allowedHeaders: "*"
allowedMethods: "*"Even after configuring both the micro‑service and the gateway, the front‑end may still receive the error “Multiple ‘Access-Control-Allow-Origin’ CORS headers are not allowed”. The response contains two Access-Control-Allow-Origin headers because both the gateway and the downstream service add them, and the Vary header is also duplicated.
Chrome extensions cannot set the Origin header due to browser restrictions.
Analysis shows that Spring Cloud Gateway, built on Spring WebFlux, first passes the request to DispatcherHandler, which delegates to RoutePredicateHandlerMapping. The DefaultCorsProcessor then adds the Vary and Access-Control-Allow-Origin headers based on the configuration.
@Override
public boolean process(@Nullable CorsConfiguration config, ServerWebExchange exchange) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
HttpHeaders responseHeaders = response.getHeaders();
// Add Vary header if not present
List<String> varyHeaders = responseHeaders.get(HttpHeaders.VARY);
if (varyHeaders == null) {
responseHeaders.addAll(HttpHeaders.VARY, VARY_HEADERS);
} else {
for (String header : VARY_HEADERS) {
if (!varyHeaders.contains(header)) {
responseHeaders.add(HttpHeaders.VARY, header);
}
}
}
if (!CorsUtils.isCorsRequest(request)) {
return true;
}
if (responseHeaders.getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) {
logger.trace("Skip: response already contains \"Access-Control-Allow-Origin\"");
return true;
}
// ... further processing and header setting ...
return true;
}After the GlobalFilters run, NettyRoutingFilter forwards the request to the downstream service and merges the response headers. Because the merge uses putAll without deduplication, duplicate headers appear.
Solutions
1. Use DedupeResponseHeader configuration
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "*"
allowedHeaders: "*"
allowedMethods: "*"
default-filters:
- DedupeResponseHeader=Vary Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRSTThe DedupeResponseHeaderGatewayFilterFactory removes duplicate values according to the chosen strategy (e.g., RETAIN_FIRST keeps the first header).
2. Implement a custom GlobalFilter to clean the response headers
@Component
public class CorsResponseHeaderFilter implements GlobalFilter, Ordered {
private static final Logger logger = LoggerFactory.getLogger(CorsResponseHeaderFilter.class);
private static final String ANY = "*";
@Override
public int getOrder() {
// Execute after NettyWriteResponseFilter
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
exchange.getResponse().getHeaders().entrySet().stream()
.filter(kv -> kv.getValue() != null && kv.getValue().size() > 1)
.filter(kv -> kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
|| kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)
|| kv.getKey().equals(HttpHeaders.VARY))
.forEach(kv -> {
if (kv.getKey().equals(HttpHeaders.VARY)) {
kv.setValue(kv.getValue().stream().distinct().collect(Collectors.toList()));
} else {
List<String> value = new ArrayList<>();
if (kv.getValue().contains(ANY)) {
value.add(ANY);
} else {
value.add(kv.getValue().get(0));
}
kv.setValue(value);
}
});
}));
}
}When using RETAIN_FIRST, the filter keeps the header we configured (usually *), avoiding the duplicate that would otherwise be returned.
In most cases, keeping the configured rule is sufficient, so RETAIN_FIRST is the recommended strategy. The DedupeResponseHeader filter can also be applied to other headers if needed.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
