29 November 2022

Preflight requests in CORS

Cross-origin requests are only preflighted if the browser thinks that the request might cause a server-side mutation. If you make a cross-origin XMLHttpRequest / fetch request, it gets classified into one of two categories: “simple” or “preflighted”.

Simple requests are ones where it is (conventionally) safe to piggyback off the actual request to determine the CORS policy. I say “conventionally” because REST/HTTP does not enforce that you only read on GET requests, or that PUT will create-or-replace, there are only conventions.

Anyway, if your request has a HEAD / GET method, the browser can assume that the origin server will not do any mutations when processing this request, so it just looks for the Access-Control-Allow-Origin header in the actual request’s response, AKA CORS policies are inferred from the actual request. This is considered safe to do, and it saves one round-trip.

For PUT / DELETE though, sending the actual request might trigger server-side mutations (like creating or deleting a row in a database), so the browser needs to make sure that the origin making this request is allowed to do so before making the actual request. It cannot piggyback off the actual request to infer CORS policies because doing so defeats the purpose of CORS — the origin was able to mutate the host without explicit permission. Sure, the origin might not get to see the host’s response, but the damage is already done. This is why the browser must “preflight” the actual request, AKA send a separate (OPTIONS) request to explicitly ask the host if the request it intends to send is allowed. This (preflight) OPTIONS request has the headers Access-Control-Request-Method that describes what method the actual request will be, and Access-Control-Request-Headers, that lists the headers the actual request will have. Only if the host server says that the origin is allowed to send a request with the method in Access-Control-Request-Method and the headers in Access-Control-Request-Headers, is the actual request sent out.

The decision to preflight a request lies entirely in the hands of the client (which is a web browser in most cases). It tries its best to not send an additional request, within the bounds of the CORS contract. Unfortunately, this decision is not based on the request method alone, there are edge cases that may cause even a GET request to be preflighted.

MDN’s CORS documentation is actually a great read, and expands on how the browser decides if a request needs to be preflighted.