Preflight CORS requests
The cross-origin request that we made from JavaScript in the previous chapter is known as a simple cross-origin request. Broadly speaking, cross-origin requests are classified as ‘simple’ when all the following conditions are met:
- The request HTTP method is one of the three CORS-safe methods:
HEAD,GETorPOST. - The request headers are all either forbidden headers or one of the four CORS-safe headers:
AcceptAccept-LanguageContent-LanguageContent-Type
- The value for the
Content-Typeheader (if set) is one of:application/x-www-form-urlencodedmultipart/form-datatext/plain
When a cross-origin request doesn’t meet these conditions, the web browser will trigger an initial ‘preflight’ request before sending the real request. The purpose of this preflight request is to determine whether the real cross-origin request will be permitted or not.
Demonstrating a preflight request
To help demonstrate how preflight requests work and what we need to do to deal with them, let’s create another example webpage under the cmd/examples/cors/ directory.
We’ll set up this webpage so it makes a request to our POST /v1/tokens/authentication endpoint. When calling this endpoint we’ll include an email address and password in a JSON request body, along with a Content-Type: application/json header. And because the header Content-Type: application/json isn’t allowed in a ‘simple’ cross-origin request, this should trigger a preflight request to our API.
Go ahead and create a new file at cmd/examples/cors/preflight/main.go:
$ mkdir -p cmd/examples/cors/preflight $ touch cmd/examples/cors/preflight/main.go
And add the code below, which follows a very similar pattern to the one we used a couple of chapters ago.
package main import ( "flag" "log" "net/http" ) // Define a string constant containing the HTML for the webpage. This consists of a <h1> // header tag, and some JavaScript which calls our POST /v1/tokens/authentication // endpoint and writes the response body inside the <div id="output"></div> tag. const html = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> <h1>Preflight CORS</h1> <div id="output"></div> <script> document.addEventListener('DOMContentLoaded', function() { fetch("http://localhost:4000/v1/tokens/authentication", { method: "POST", headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'alice@example.com', password: 'pa55word' }) }).then( function (response) { response.text().then(function (text) { document.getElementById("output").innerHTML = text; }); }, function(err) { document.getElementById("output").innerHTML = err; } ); }); </script> </body> </html>` func main() { addr := flag.String("addr", ":9000", "Server address") flag.Parse() log.Printf("starting server on %s", *addr) err := http.ListenAndServe(*addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(html)) })) log.Fatal(err) }
If you’re following along, go ahead and run this application:
$ go run ./cmd/examples/cors/preflight 2021/04/17 18:47:55 starting server on :9000
Then open a second terminal window and start our regular API application at the same time with http://localhost:9000 as a trusted origin:
$ go run ./cmd/api -cors-trusted-origins="http://localhost:9000" time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established" time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
Once both are running, open your web browser and navigate to http://localhost:9000. If you look at the console log in your developer tools, you should see a message similar to this:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:4000/v1/tokens/authentication. (Reason: header ‘content-type’ is not allowed according to header ‘Access-Control-Allow-Headers’ from CORS preflight response).
We can see that there are two requests here marked as ‘blocked’ by the browser:
- An
OPTIONS /v1/tokens/authenticationrequest (this is the preflight request). - A
POST /v1/tokens/authenticationrequest (this is the ‘real’ request).
Let’s take a closer look at the preflight request in the network tab of the developer tools:
The interesting thing here is the preflight request headers. They might look slightly different for you depending on the browser you’re using, but broadly they should look something like this:
Accept: */* Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.5 Access-Control-Request-Headers: content-type Access-Control-Request-Method: POST Cache-Control: no-cache Connection: keep-alive Host: localhost:4000 Origin: http://localhost:9000 Pragma: no-cache Referer: http://localhost:9000/ User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0
There are three headers here which are relevant to CORS:
Origin— As we saw previously, this lets our API know what origin the preflight request is coming from.Access-Control-Request-Method— This lets our API know what HTTP method will be used for the real request (in this case, we can see that the real request will be aPOST).Access-Control-Request-Headers— This lets our API know what HTTP headers will be sent with the real request (in this case we can see that the real request will include acontent-typeheader).
It’s important to note that Access-Control-Request-Headers won’t list all the headers that the real request will use. Only headers that are not CORS-safe or forbidden will be listed. If there are no such headers, then Access-Control-Request-Headers may be omitted from the preflight request entirely.
Responding to preflight requests
In order to respond to a preflight request, the first thing we need to do is identify that it is a preflight request — rather than just a regular (possibly even cross-origin) OPTIONS request.
To do that, we can leverage the fact that preflight requests always have three components: the HTTP method OPTIONS, an Origin header, and an Access-Control-Request-Method header. If any one of these pieces is missing, we know that it is not a preflight request.
Once we identify that it is a preflight request, we need to send a 200 OK response with some special headers to let the browser know whether or not it’s OK for the real request to proceed. These are:
- An
Access-Control-Allow-Originresponse header, which reflects the value of the preflight request’sOriginheader (just like in the previous chapter). - An
Access-Control-Allow-Methodsheader listing the HTTP methods that can be used in real cross-origin requests to the URL. - An
Access-Control-Allow-Headersheader listing the request headers that can be included in real cross-origin requests to the URL.
In our case, we could set the following response headers to allow cross-origin requests for all of our endpoints:
Access-Control-Allow-Origin: <reflected trusted origin> Access-Control-Allow-Methods: OPTIONS, PUT, PATCH, DELETE Access-Control-Allow-Headers: Authorization, Content-Type
When the web browser receives these headers, it compares the values to the method and (case-insensitive) headers that it wants to use in the real request. If the method or any of the headers are not allowed, then the browser will block the real request.
Updating our middleware
Let’s put this into action and update our enableCORS() middleware so it intercepts and responds to any preflight requests. Specifically, we want to:
- Set a
Vary: Access-Control-Request-Methodheader on all responses, as the response will be different depending on whether or not this header exists in the request. - Check whether the request is a preflight cross-origin request or not. If it’s not, then we should allow the request to proceed as normal.
- Otherwise, if it is a preflight cross-origin request, then we should add the
Access-Control-Allow-MethodsandAccess-Control-Allow-Headersheaders as described above and send a200 OKresponse.
Go ahead and update the cmd/api/middleware.go file like so:
package main ... func (app *application) enableCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Vary", "Origin") // Add the "Vary: Access-Control-Request-Method" header. w.Header().Add("Vary", "Access-Control-Request-Method") origin := r.Header.Get("Origin") if origin != "" { for i := range app.config.cors.trustedOrigins { if origin == app.config.cors.trustedOrigins[i] { w.Header().Set("Access-Control-Allow-Origin", origin) // Check if the request has the HTTP method OPTIONS and contains the // "Access-Control-Request-Method" header. If it does, then we treat // it as a preflight request. if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { // Set the necessary preflight response headers, as discussed // previously. w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PUT, PATCH, DELETE") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") // Write the headers along with a 200 OK status and return from // the middleware with no further action. w.WriteHeader(http.StatusOK) return } break } } } next.ServeHTTP(w, r) }) }
There are a couple of additional things to point out here:
When we respond to a preflight request we deliberately send the HTTP status
200 OKrather than204 No Content— even though there is no response body. This is because certain browser versions may not support204 No Contentresponses and subsequently block the real request.If you allow the
Authorizationheader in cross-origin requests, like we are in the code above, it’s important to not set the wildcardAccess-Control-Allow-Origin: *header or reflect theOriginheader without checking against a list of trusted origins. Otherwise, this would leave your service vulnerable to a distributed brute-force attack against any authentication credentials that are passed in that header.
OK, let’s try this out. Restart your API, again setting http://localhost:9000 as a trusted origin like so:
$ go run ./cmd/api -cors-trusted-origins="http://localhost:9000" time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established" time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
Then open http://localhost:9000 in your browser again. This time you should see that the cross-origin fetch() to POST /v1/tokens/authentication succeeds, and you now get an authentication token in the response. Similar to this:
Additional information
Caching preflight responses
If you want, you can also add an Access-Control-Max-Age header to your preflight responses. This indicates the number of seconds that the information provided by the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers can be cached by the browser.
For example, to allow the values to be cached for 60 seconds you can set the following header on your preflight response:
Access-Control-Max-Age: 60
If you don’t set an Access-Control-Max-Age header, current versions of Chrome/Chromium and Firefox will default to caching these preflight response values for 5 seconds. Older versions or other browsers may have different defaults, or not cache the values at all.
Setting a long Access-Control-Max-Age duration might seem like an appealing way to reduce requests to your API — and it is! But you also need to be careful. Not all browsers provide a way to clear the preflight cache, so if you send back the wrong headers the user will be stuck with them until the cache expires.
If you want to disable caching altogether, you can set the value to -1:
Access-Control-Max-Age: -1
It’s also important to be aware that browsers may impose a hard maximum on how long the headers can be cached for. The MDN documentation says:
- Firefox caps this at 24 hours (86400 seconds).
- Chromium (prior to v76) caps at 10 minutes (600 seconds).
- Chromium (starting in v76) caps at 2 hours (7200 seconds).
Preflight wildcards
If you have a complex or rapidly changing API then it might be awkward to maintain a hard-coded safelist of methods and headers for the preflight response. You might think: I just want to allow all HTTP methods and headers for cross-origin requests.
In this case, both the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers allow you to use a wildcard * character like so:
Access-Control-Allow-Methods: * Access-Control-Allow-Headers: *
But using these comes with some important caveats:
Wildcards in these headers are currently only supported by 95% of browsers. Any browsers which don’t support them will block the preflight request.
The
Authorizationheader cannot be wildcarded. Instead, you will need to include this explicitly in the header likeAccess-Control-Allow-Headers: Authorization, *.Wildcards are not supported for credentialed requests (those with cookies or HTTP basic authentication). For these, the character
*will be treated as the literal string"*", rather than as a wildcard.