Let's Go Further Cross-origin requests › Preflight CORS requests
Previous · Contents · Next
Chapter 17.4.

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:

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.

File: cmd/examples/cors/preflight/main.go
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).

17.04-01.png

We can see that there are two requests here marked as ‘blocked’ by the browser:

Let’s take a closer look at the preflight request in the network tab of the developer tools:

17.04-02.png

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:

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:

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:

  1. Set a Vary: Access-Control-Request-Method header on all responses, as the response will be different depending on whether or not this header exists in the request.
  2. 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.
  3. Otherwise, if it is a preflight cross-origin request, then we should add the Access-Control-Allow-Methods and Access-Control-Allow-Headers headers as described above and send a 200 OK response.

Go ahead and update the cmd/api/middleware.go file like so:

File: cmd/api/middleware.go
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:

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:

17.04-03.png

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:

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: