Cross-origin resource sharing (CORS) is a topic that backend engineers often find both familiar and confusing.
Over the past two months, I have been involved in incubating an educational product as an architect, which gave me an unforgettable journey into cross-origin issues.
This article shares my experiences and thoughts on cross-origin knowledge, hoping to provide some inspiration.
1 Encountering Cross-Origin
Our product has multiple clients: institution side, government side, parent side, etc. Each side has its own domain name. Some are accessed via PC, some through WeChat public accounts, and some via H5 pages after scanning QR codes.
The API gateway uses a unified domain api.training.com, with Nginx configured to forward requests.
Usually, when we mention cross-origin, we refer to CORS.
CORS is a W3C standard that stands for "Cross-origin Resource Sharing". It requires support from both the browser and the server. It allows browsers to send XMLHttpRequest requests to cross-origin servers, overcoming the restriction that AJAX can only be used with the same origin.
So how do we define the same origin? Let's first look at a typical website address:
Same origin means that the protocol, domain, and port number are exactly the same.
For example, compare URL http://www.training.com/dir/page.html with the following:
When a user accesses an application (http://admin.training.com) via a browser, the API call goes to a different origin (http://api.training.com), which is a clear cross-origin scenario.
2 Detailed Explanation of CORS
The Cross-Origin Resource Sharing standard introduces a set of HTTP headers that allow servers to declare which origins are permitted to access certain resources.
For HTTP request methods that may cause side effects on server data (especially methods other than GET, or POST requests with certain MIME types), the specification requires browsers to first send a preflight request using the OPTIONS method. This verifies whether the server allows the cross-origin request.
Only after the server confirms permission does the browser send the actual HTTP request. In the preflight response, the server can also notify the client whether credentials (including Cookies and HTTP authentication data) need to be included.
2.1 Simple Requests
When the request meets all the following conditions, CORS uses a simple request; otherwise, it uses a preflight request.
- Uses one of the methods: GET, POST, HEAD.
- Only uses the following safe headers, with no other custom headers set:
- Accept
- Accept-Language
- Content-Language
- Content-Type limited to these three: text/plain, multipart/form-data, application/x-www-form-urlencoded
- HTML header fields: DPR, Download, Save-Data, Viewport-Width, Width
- No event listeners are registered on any
XMLHttpRequestUploadobject; theXMLHttpRequestUploadobject can be accessed viaXMLHttpRequest.upload. - No
ReadableStreamobject is used in the request.
For simple requests, the browser directly sends the cross-origin request with an Origin header, indicating its a cross-origin request. After receiving the request, the server validates it according to its own cross-origin rules, returning Access-Control-Allow-Origin and Access-Control-Allow-Methods headers in the response.
The response includes the cross-origin header Access-Control-Allow-Origin. Using Origin and Access-Control-Allow-Origin achieves the simplest access control. In this example, the server responds with Access-Control-Allow-Origin: *, meaning the resource can be accessed by any external domain. If the server only allows access from http://admin.training.com, the header would be:
Access-Control-Allow-Origin: http://admin.training.com
Now, only http://admin.training.com can access this resource.
2.2 Preflight Requests
When the browser detects that a request is not a simple request, it does not execute the request immediately. Instead, it triggers a preflight request mode. The preflight request first sends an OPTIONS request to ask the target server whether it allows the cross-origin request from the current domain. Only after receiving permission does the browser send the actual HTTP request.
The OPTIONS request includes the following headers:
After receiving the OPTIONS request, the server sets headers to communicate with the browser and determine whether to allow the request.
If the preflight request passes, the browser then sends the actual cross-origin request.
3 Backend Configuration
I tried two backend configurations, both of which have been running stably for two months of testing.
- Nginx configuration recommended by MDN
- Spring Boot's built-in CorsFilter configuration
Nginx Configuration Recommended by MDN
Nginx configuration works at the request forwarding layer.
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
#
# Custom headers and headers various browsers *should* be OK with but aren't
#
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
#
# Tell client that this pre-flight info is valid for 20 days
#
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
}
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
}
}
When configuring the Access-Control-Allow-Headers property, since there are many custom headers including signatures and tokens, I set Access-Control-Allow-Headers to * for simplicity.
This works fine in Chrome and Firefox, but in IE 11, it throws an error:
Request header content-type is not present in the Access-Control-Allow-Headers list.
It turns out that IE 11 requires the value of Access-Control-Allow-Headers in the preflight response to be comma-separated.
Spring Boot's Built-in CorsFilter
First, the basic framework has the following default cross-origin configuration:
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
.allowCredentials(true)
.allowedHeaders("*")
.maxAge(3600);
}
However, after deployment, the application still reported a CORS error:
Looking at the Nginx and Tomcat logs, only an OPTION request was received. The Spring Boot application has an interceptor called ActionInterceptor that extracts the token from the header, queries the user service for user information, and stores it in the request. When no token data is available, it returns JSON-formatted data to the frontend.
It seems that the CorsMapping did not take effect.
Why? It's because of the execution order. The following diagram shows the order of filters, interceptors, and controllers.
The DispatchServlet.doDispatch() method is the core entry point of Spring MVC.
// Determine handler for the current request.
mappedHandler = getHandler(processedRequest);
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
So where is the CorsMapping initialized? After debugging, I found it in AbstractHandlerMapping.
protected HandlerExecutionChain getCorsHandlerExecutionChain(HttpServletRequest request,
HandlerExecutionChain chain, CorsConfiguration config) {
if (CorsUtils.isPreFlightRequest(request)) {
HandlerInterceptor[] interceptors = chain.getInterceptors();
chain = new HandlerExecutionChain(new PreFlightHandler(config), interceptors);
}
else {
chain.addInterceptor(new CorsInterceptor(config));
}
return chain;
}
The code checks for preflight requests and handles them via PreFlightHandler.handleRequest(), but this happens after the regular business interceptors.
I ultimately chose CorsFilter for two reasons:
- Filters have the highest execution order.
- Debugging the CorsFilter source code revealed many detailed handling aspects.
private CorsConfiguration corsConfig() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedOrigin("*");
corsConfiguration.addAllowedHeader("*");
corsConfiguration.addAllowedMethod("*");
corsConfiguration.setAllowCredentials(true);
corsConfiguration.setMaxAge(3600L);
return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig());
return new CorsFilter(source);
}
In the code below, when allowHeader is a wildcard *, the CorsFilter sets Access-Control-Allow-Headers by concatenating values from Access-Control-Request-Headers with commas, which avoids the issue with IE 11 response headers.
public List<String> checkHeaders(@Nullable List<String> requestHeaders) {
if (requestHeaders == null) {
return null;
}
if (requestHeaders.isEmpty()) {
return Collections.emptyList();
}
if (ObjectUtils.isEmpty(this.allowedHeaders)) {
return null;
}
boolean allowAnyHeader = this.allowedHeaders.contains(ALL);
List<String> result = new ArrayList<>(requestHeaders.size());
for (String requestHeader : requestHeaders) {
if (StringUtils.hasText(requestHeader)) {
requestHeader = requestHeader.trim();
if (allowAnyHeader) {
result.add(requestHeader);
}
else {
for (String allowedHeader : this.allowedHeaders) {
if (requestHeader.equalsIgnoreCase(allowedHeader)) {
result.add(requestHeader);
break;
}
}
}
}
}
return (result.isEmpty() ? null : result);
}
Browser execution results:
4 Preflight Response Code: 200 vs 204
After completing the backend configuration, a team member asked me, "Should the preflight response code be 200 or 204?" This question really stumped me.
Our API gateway returns 200 for preflight responses, and the CorsFilter also returns 200.
The MDN examples all use 204 for preflight responses.
I resorted to Google and found that developers of the well-known API gateway Kong had also discussed this issue.
- MDN previously recommended 200 for preflight responses, so Kong synchronized with MDN to use 200.
- Later, MDN changed the response code to 204, and Kong developers debated whether to follow suit. The core of the debate was: Is it necessary? The 200 response code works well and seems likely to continue working. Switching to 204 might introduce unknown issues.
- Ultimately, framework developers rely on browser implementation. There is insufficient authoritative information to guide developers, and relevant knowledge is scattered across the internet, with incomplete details and partial solutions, causing confusion.
In the end, Kong's source code still uses 200 for preflight responses, not following MDN's change.
I checked major websites, and 95% of preflight responses use 200. After two months of testing, Nginx configured with 204 for preflight responses works perfectly in mainstream browsers like Chrome, Firefox, and IE 11.
So, 200 works everywhere, and 204 is also well-supported in current mainstream browsers.
5 Chrome: Insecure Private Network
I thought the cross-origin issues were resolved, but there was a small incident.
Our product director needed to demonstrate the product to a client, and I was responsible for setting up the demo environment. I applied for a domain, prepared Alibaba Cloud servers, packaged and deployed the application—everything went smoothly.
However, when accessing the demo environment from the company intranet, one page consistently reported a CORS error similar to the one below:
The error type was: InsecurePrivateNetwork.
This was completely different from the cross-origin errors I had encountered before. I panicked. After a quick Google search, I found that this is a new feature in Chrome 94, which can be manually disabled.
- Open the tab
chrome://flags/#block-insecure-private-network-requests. - Set
Block insecure private network requeststoDisabled, then restart Chrome. This disables the feature.
But this is only a temporary fix. Strangely, when accessing the demo environment from outside the company intranet, everything worked fine, and the problematic page loaded correctly.
Looking at the official documentation, CORS-RFC1918 specifies that the following types of requests are affected:
- Public network accessing private network
- Public network accessing local devices
- Private network accessing local devices
I then identified the problematic third-party API address. Many of our company's products depend on this API. When accessed from the intranet, the domain resolves to an IP like 172.16.xx.xx.
This IP falls within the private network range defined by RFC1918:
10.0.0.0 - 10.255.255.255 (10/8 prefix)
172.16.0.0 - 172.31.255.255 (172.16/12 prefix)
192.168.0.0 - 192.168.255.255 (192.168/16 prefix)
When accessing the page from the intranet via Chrome, the insecure private network interception is triggered.
How to solve it? The official solution involves two steps:
- Private networks can only be accessed via HTTPS.
- In the future, add specific preflight headers, such as
Access-Control-Request-Private-Network.
There are also some temporary workarounds:
- Disable the Chrome feature.
- Use another browser like Firefox.
- Disconnect from the intranet and use a mobile hotspot.
- Modify local hosts to bind to the external IP.
Based on the official solution, using HTTPS in production completely resolved the cross-origin issue when accessing from the company intranet.
6 Review
The API gateway is very suitable for the current product architecture. At the beginning of the architecture design, multiple clients call our API gateway. The API gateway supports SaaS deployment and private deployment, has its own domain, and provides a robust signature algorithm. Considering the tight timeline, the team's familiarity with the API gateway, and the time cost of deploying multiple environments, I made some trade-offs to deliver quickly.
The API calls use a unified domain api.training.com, with Nginx configured for request forwarding. Simultaneously, I coordinated with the frontend lead to ensure the frontend-backend protocol matches our API gateway, preparing for a future migration to the gateway.
The API gateway can handle authentication, rate limiting, grayscale deployments, and CORS configuration. Internal services don't need to worry about cross-origin issues.
Throughout this process, my mindset changed. Initially, I underestimated the problem, but gradually I studied the principles of CORS, understood the pros and cons of different solutions, and things became smoother. I also noticed that some teams had already reported the Chrome insecure private network issue and provided solutions. For technical managers, it's essential to pay attention to issues reported in projects, analyze them, and prepare playbooks. This way, when similar problems arise, the response is organized and efficient.
7 Final Thoughts
In 2017, I attended a talk by Chen Hao (aka "Left Ear Mouse"), where he shared a story.
Roughly: "A company had a bug where users were charged, but calls to a third-party API frequently failed due to network issues. The company's best engineer spent a week without finding the root cause. Chen Hao, who was reading the book 'TCP/IP Illustrated', used netstat and found the connection state was CLOSE_WAIT, indicating the other side had disconnected. He suspected the problem was on the other system. He went to help them debug their code and found a condition that caused the application to close the connection prematurely. The problem was solved in less than two hours."
Thinking back to Chen Hao's story and reflecting on my own cross-origin journey, I deeply feel that the devil is in the details, and the solution is often hidden in an overlooked detail.