CORS-less Cross-Origin Requests

7 min read Original article ↗

Indrek Juhkam

CORS, you so slow!

Cross-Origin Resource Sharing (CORS) is a mechanism that uses additional HTTP headers to tell a browser to let a web application running at one origin (domain) have permission to access selected resources from a server at a different origin. A web application makes a cross-origin HTTP request when it requests a resource that has a different origin (domain, protocol, and port) than its own origin. — https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

CORS in general looks like a useful tool until you see the performance impact. At Glia, our public REST API (API from now on) and our Web Client App (APP from now on) live in different subdomains. Sending a request from the APP to the API will cause a preflight (OPTIONS) request. This operation on its own is relatively cheap, the OPTIONS request is sent with only a few headers and the server responds with information about what is permitted.

Press enter or click to view image in full size

In a development environment where everything is running on the same machine, this request is almost instant, but the story is very different in the wild world. The picture on the left shows timings of a preflight request that was sent from an Eastern European location to a server which was located in the Western US. This preflight request added almost an extra half of a second delay. Such delays are very noticeable to us and to our clients.

Even though browsers have mechanisms to reduce the performance impact they all fall short in most of the real use cases:

Use caching?

CORS caching is very restrictive:

  • Chrome sets an upper limit for preflight request cache expiration which is 10 minutes. Firefox has it set to 1 day.
  • Many RESTful URLs include some kind of resource ID in the URL. Caching DELETE /articles/1 still means that the browser has to do a new preflight request when invoking DELETE /articles/2.

Send only simple requests?

CORS specification says that preflight requests are not mandatory for Simple Requests. A request is considered simple if it follows these rules:

  • The HTTP method is HEAD, GET or POST.
  • Only Accept, Accept-Language, Content-Language and Content-Type headers are used.
  • Content-Type header is set to application/x-www-form-urlencoded, multipart/form-data or text/plain

In our case, most of our APIs expect JSON payloads. We also use other HTTP methods like PUT, PATCH and DELETE. We could make it work by sending _method attribute to route the requests and by using text/plain content type, despite the contents being in JSON format. However, we are committed to having clean APIs so this is not an option for us.

Avoiding CORS Requests

As usual in the world of web development, there are ways to work around the restrictions imposed by browsers.

document.domain

This solution works only when both the site and the server share the same base domain. Lets say you have a public API on api.example.com and a user-facing site (the APP) on app.example.com. Making a request from the APP to the API will trigger a preflight request because the subdomains are different.

However, we can avoid the preflight request with a simple iframe. First we need to expose a new endpoint in our API that returns the following HTML.

<!-- https://api.example.com/app-proxy.html -->
<!DOCTYPE HTML>
<html>
<head>
<script>
document.domain = 'example.com';
window.parent.fetch = window.fetch.bind(window);
</script>
</head>
</html>

There are two important things to notice here:

  1. First we set document.domain = 'example.com'. Browsers allow changing the current document.domain to a super-domain. E.g. It is possible to change foo.example.com to example.com, but it is not possible to change it to bar.example.com nor something.else.com.
  2. We override the parent window fetch function with the current window
    fetch function.

Now in the APP we can add following two lines of code:

<script>document.domain = ‘example.com’</script>
<iframe src=”https://api.example.com/app-proxy.html"></iframe>

First we change the current document domain to example.com. Now both API and APP are using the same origin. This allows us to execute JavaScript between the frames. The second line includes the page we exposed in the API as an iframe. This overrides the current window fetch function with the fetch function that was loaded from the API domain.

Now we can use Fetch API as usual to make requests to the API without
any preflight requests.

Get Indrek Juhkam’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

Benefits:

  • Transparent. The code using fetch does not need to know about the document.domain changes. The site will do regular requests with preflight requests when loading the iframe fails.
  • Cacheable. The proxy HTML doesn’t need to change except when we want to change the domain which should be a very rare occurrence. We can serve it from the API server with a long cache or even upload it to a CDN.
  • Low effort. All it entails is exposing one static page and adding two lines of code to the user-facing site.

Constraints:

  • The sites can have different subdomains but they have to have the same base domain.
  • Includes an iframe
  • This works only with one API domain. Overwriting fetch is not an option when having more than one API domain. We can however expose them as different fetch functions (e.g. window.api1Fetch and window.api2Fetch).

postMessage

postMessage approach uses iframes like document.domain solution but it
allows having sites on completely different domains.

For this we need to expose an endpoint in the API which serves an HTML page — the proxy. We can then use Window.postMessage() to send messages between the APP and the proxy page, which then sends them to the API without preflight requests.

Benefits:

  • Supports different domains. It doesn’t matter where the API is located. The entire domain can be different.

Constraints:

  • Includes an iframe
  • Needs a custom adapter for making requests. With document.domain method we were able to continue to use the Fetch API exactly as before. Here we need a custom adapter to facilitate the communication between the frames and ensure everything works even if loading the iframe fails.

Check out the xdomain library when going with this approach. It currently doesn’t support the Fetch API but regular XHR requests should be fine.

Reverse Proxy

This solution works by exposing the API on the current domain. E.g. If you have API served on api.example.com and your APP is on app.example.com then you can set up a reverse proxy on app.example.com/api that proxies all requests to api.example.com.

Benefits:

  • No iframes.
  • Supports different domains. It doesn’t matter where the API is located. The entire domain can be different.

Constraints:

  • Needs a reverse proxy. If there already is a web server (e.g. NGINX) that is serving the APP content then this can be trivial.
  • Cannot be used when the user-facing site is not owned by us. E.g. in Glia we provide a JavaScript API to our clients. It’s not reasonable to force each of our clients to set up a reverse proxy like world-largest-bank.com/glia-api just so that we could avoid cross domain requests from our JS API.
  • It changes the URLs which may be annoying.

WebSocket Proxy

This is the most complex solution. It can be a useful approach when other approaches are not possible or when users are already connected to an existing WebSocket server.

For this we need to set up a web server that accepts WebSocket connections. The server then translates WebSocket messages to a HTTP requests and executes them. The responses are then translated back to WebSocket messages and are sent back to the user.

Benefits:

  • No iframes.
  • Supports different domains.

Constraints:

  • Needs a WebSocket connection.
  • Complex. Needs a server which knows how to translate WebSocket messages to HTTP requests.
  • Security concerns. All proxied requests are now using the WebSocket Proxy server IP. This can end up badly when the WebSocket Proxy server does not whitelist allowed domains, do rate-limiting, etc.

Conclusion

CORS can have a high performance impact for sites that do a lot of cross-origin requests. Browsers have features like CORS caching, but these are not very efficient. The best option is to avoid preflight requests altogether whenever possible.