Deep dive in CORS: History, how it works, and best practices
Table of Contents
The error in your browser’s console #
No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://example.com/
Access to fetch at ‘https://example.com’ from origin ‘http://localhost:3000’ has been blocked by CORS policy.
I am sure you’ve seen one of these errors, or a variation, in your browser’s console. If you have not β don’t fret, you soon will. There are enough CORS errors for all developers out there.
These popping-up during development can be annoying. But in fact, CORS is an incredibly useful mechanism in a world of misconfigured web servers, hostile actors on the web and organizations pushing the web standards ahead.
But let’s go back the beginning…
In the beginning was the first subresource #
A subresource is an HTML element that is requested to be embedded into the
document, or executed in its context. In the year of
1993, the
first subresource <img>
was introduced. By introducing <img>
, the web got
prettier. And more complex.
You see, if your browser would render a page with an <img>
on it, it would
actually have to go fetch that subresource from an origin. When a browser
fetches said subresource from an origin that does not reside on the same
scheme, fully qualified hostname or port β that’s a cross-origin request.
Origins & cross-origin #
An origin is identified by a triple: scheme, fully qualified hostname and port.
For example, http://example.com
and https://example.com
are different
origins βΒ the first uses http
scheme and the second https
. Also, the
default http
port is 80, while the https
is 443. Therefore, in this
example, the two origins differ by scheme and port, although the host is the
same (example.com
).
You get the idea β if any of the three items in the triple are different, then the origin is different.
As an exercise if we run a comparison of the
https://blog.example.com/posts/foo.html
origin against other origins, we
would get the following results:
URL | Result | Reason |
---|---|---|
https://blog.example.com/posts/bar.html |
Same | Only the path differs |
https://blog.example.com/contact.html |
Same | Only the path differs |
http://blog.example.com/posts/bar.html |
Different | Different protocol |
https://blog.example.com:8080/posts/bar.html |
Different | Different port (https:// is port 443 by default) |
https://example.com/posts/bar.html |
Different | Different host |
A cross-origin request means, for example, a resource (i.e. page) such as
http://example.com/posts/bar.html
that would try to render a subresource from
the https://example.com
origin (note the scheme change!).
The many dangers of cross-origin requests #
Now that we defined what same- and cross-origin is, let’s see what is the big deal.
When we introduced <img>
to the web, we opened the floodgates. Soon after the
web got <script>
, <frame>
, <video>
, <audio>
, <iframe>
, <link>
,
<form>
and so on. These subresources can be fetched by the browser after
loading the page, therefore they can all be same- or cross-origin requests.
Let’s travel to an imaginary world where CORS does not exist and web browsers allow all sorts of cross-origin requests.
Imagine I got a page on my website evil.com
with a <script>
. On the surface
it looks like a simple page, where you read some useful information. But in the
<script>
, I have specially crafted code that will send a specially-crafted
request to bank’s DELETE /account
endpoint. Once you load the page, the
JavaScript is executed and an AJAX call hits the bank’s API.
Mind-blowing βΒ imagine while reading some information on a web page, you get an email from your bank that you’ve successfully deleted your account. I know I know… if it was THAT easy to do anything with a bank’s. I digress.
For my evil <script>
to work, as part of the request your browser would also
have to send your credentials (cookies) from the bank’s website. That’s how the
bank’s servers would identify you and know which account to delete.
Let’s look at a different, not-so-evil scenario.
I want to detect folks that work for Awesome Corp, whose internal website
is on intra.awesome-corp.com
. On my website, dangerous.com
I got an <img src="https://intra.awesome-corp.com/avatars/john-doe.png">
.
For users that do not have a session active with intra.awesome-corp.com
, the
avatar won’t render βΒ it will produce an error. But, if you’re logged in the
intranet of Awesome Corp., once you open my dangerous.com
website I’ll know
that you have access.
That means that I will be able to derive some information about you. While it’s definitely harder for me to craft an attack, the knowledge that you have access to Awesome Corp. is still a potential attack vector.
While these two are overly-simplistic examples, it is this kind of threats that have made the same-origin policy & CORS necessary. These are all different dangers of cross-origin requests. Some have been mitigated, others can’t be mitigated β they’re rooted in the nature of the web. But for the plethora of attack vectors that have been squashed βΒ it’s because of CORS.
But before CORS, there was the same-origin policy.
Same-origin policy #
The same-origin policy prevents cross-origin attacks by blocking read access to
resources loaded from a different origin. This policy still allows some tags,
like <img>
, to embeds resources from a different origin.
The same-origin policy was introduced by Netscape Navigator 2.02 in 1995, originally intended to protect cross-origin access to the DOM.
Even though same-origin policy implementations are not required to follow an exact specification, all modern browsers implement some form of it. The principles of the policy are described in RFC6454 of the Internet Engineering Task Force (IETF).
The implementation of the same-origin policy is defined with this ruleset:
Tags | Cross-origin | Note |
---|---|---|
<iframe> |
Embedding permitted | Depends on X-Frame-Options |
<link> |
Embedding permitted | Proper Content-Type might be required |
<form> |
Writing permitted | Cross-origin writes are common |
<img> |
Embedding permitted | Cross-origin reading via JavaScript and loading it in a <canvas> is forbidden |
<audio> / <video> |
Embedding permitted | |
<script> |
Embedding permitted | Access to certain APIs might be forbidden |
Same-origin policy solves many challenges, but it is pretty restrictive. In the age of single-page applications and media-heavy websites, same-origin does not leave a lot of room for relaxation of or fine-tuning of these rules.
CORS was born with the goals to relax the same-origin policy and to fine-tune cross-origin access.
Enter CORS #
So far we covered what is an origin, how it’s defined, what the drawbacks of cross-origin requests are and the same-origin policy that browsers implement.
Now it’s time to familiarize ourselves with Cross Origin Resource Sharing (CORS). CORS is a mechanism that allows control of access to subresources on a web page over a network. The mechanism classifies three different categories of subresource access:
- Cross-origin writes
- Cross-origin embeds
- Cross-origin reads
Before we go on to explain each of these categories, it’s important to realize that although your browser (by default) might allow a certain type of cross-origin request, that does not mean that said request will be accepted by the server.
Cross-origin writes are links, redirects, and form submissions. With CORS active in your browser, these are all allowed. There is also a thing called preflight request that fine-tunes cross-origin writes, so while some writes might be permitted by default it doesn’t mean they can go through in practice. We’ll look into that a bit later.
Cross-origin embeds are subresources loaded via: <script>
, <link>
,
<img>
, <video>
, <audio>
, <object>
, <embed>
, <iframe>
and more.
These are all allowed by default. <iframe>
is a special one β as it’s
purpose is to literally load a different page inside the frame, its
cross-origin framing can be controlled by using the X-Frame-options
header.
When it comes to <img>
and the other embeddable subresources β it’s in their
nature to trigger cross-origin requests. That’s why in CORS differentiates
between cross-origin embeds and cross-origin reads, and treats them
differently.
Cross-origin reads are subresources loaded via AJAX / fetch
calls. These
are by default blocked in your browser. There’s the workaround of embedding
such subresources in a page, but such tricks are handled by another policy
present in modern browsers.
If your browser is up to date, all of these heuristics are already implemented in it.
Cross-origin writes #
Cross-origin writes can be the very problematic. Let’s look into an example and see CORS in action.
First, we’ll have a simple Crystal (using Kemal) HTTP server:
require "kemal"
port = ENV["PORT"].to_i || 4000
get "/" do
"Hello world!"
end
get "/greet" do
"Hey!"
end
post "/greet" do |env|
name = env.params.json["name"].as(String)
"Hello, #{name}!"
end
Kemal.config.port = port
Kemal.run
It simply takes a request at the /greet
path, with a name
in the request
body, and returns a Hello #{name}!
. To run this tiny Crystal server, we can
boot it with:
$ crystal run server.cr
This will boot the server and listen on localhost:4000
. If we navigate to
localhost:4000
in our browser, we will be presented a simple “Hello World”
page:
Now that we know our server is running, let’s execute a POST /greet
to the
server listening on localhost:4000
, from the console of our browser page. We
can do that by using fetch
:
fetch(
'http://localhost:4000/greet',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ilija'})
}
).then(resp => resp.text()).then(console.log)
Once we run it, we will see the greeting come back from the server:
This was a POST
request, but it was not cross-origin. We sent the request
from the browser where http://localhost:4000
(the origin) was rendered, to
that same origin.
Now, let’s try the same request, but cross-origin. We will open
https://google.com
and try to send that same request from that tab in our
browser:
We managed to get the famous CORS error. Although our Crystal server can fulfil the request, our browser is protecting us from ourselves. It is basically telling us that a website that we have opened wants to make changes to another website as ourselves.
In the first example, where we sent the request to
http://localhost:4000/greet
from the tab that rendered
http://localhost:4000
, our browser looks at that request and lets it through
because it appears that our website is calling our server (which is fine). But
in the second example where our website (https://google.com
) wants to write
to http://localhost:4000
, then our browser flags that request and does not
let it go through.
Preflight requests #
If we look deeper in our developer console, in the Network tab in particular, we will in fact notice two requests in place of the one that we sent:
What is interesting to notice is that the first request has a HTTP method of
OPTIONS
, while the second has POST.
If we explore the OPTIONS
request we will see that this is a request that has
been sent by our browser prior to sending our POST
request:
What is interesting is that even though the response to the OPTIONS
request
was a HTTP 200, it was still marked as red in the request list. Why?
This is the preflight request that modern browsers do. A preflight request is performed for requests which CORS deems as complex. The criteria for complex request is:
- A request that uses methods other than
GET
,POST
, orHEAD
- A request that includes headers other than
Accept
,Accept-Language
orContent-Language
- A request that has a
Content-Type
header value other thanapplication/x-www-form-urlencoded
,multipart/form-data
, ortext/plain
Therefore in the above example, although we send a POST
request, the browser
considers our request complex due to the Content-Type: application/json
header.
If we would change our server to handle text/plain
content (instead of JSON),
we can work around the need for a preflight request:
require "kemal"
get "/" do
"Hello world!"
end
get "/greet" do
"Hey!"
end
post "/greet" do |env|
body = env.request.body
name = "there"
name = body.gets.as(String) if !body.nil?
"Hello, #{name}!"
end
Kemal.config.port = 4000
Kemal.run
Now, when we can send our request with the Content-type: text/plain
header:
fetch(
'http://localhost:4000/greet',
{
method: 'POST',
headers: {
'Content-Type': 'text/plain'
},
body: 'Ilija'
}
)
.then(resp => resp.text())
.then(console.log)
Now, while the preflight request will not be sent, the CORS policy of the browser will keep on blocking:
But because we have crafted a request which does not classify as complex, our browser actually won’t block the request:
Simply put: our server is misconfigured to accept text/plain
cross-origin
requests, without any other protection in place, and our browser can’t do much
about that. But still, it does the next best thing β it does not expose our
opened page / tab to the response of that request. Therefore in this case, CORS
does not block the request - it blocks the response.
The CORS policy of our browser considers this effectively a cross-origin read,
because although the request is sent as POST
, the Content-type
header value
makes it essentially the same as a GET
. And cross-origin reads are blocked by
default, hence the blocked response we are seeing in our network tab.
Working around preflight requests like in the example above is not recommended.
In fact, if you expect that your server will have to gracefully handle preflight requests,
it should implement the OPTIONS
endpoints and return the correct headers.
When implementing the OPTIONS
endpoint, you need to know that the preflight
request of the browser looks for three headers in particular that can be
present on the response:
Access-Control-Allow-Methods
β it indicates which methods are supported by the responseβs URL for the purposes of the CORS protocol.Access-Control-Allow-Headers
- it indicates which headers are supported by the responseβs URL for the purposes of the CORS protocol.Access-Control-Max-Age
- it indicates the number of seconds (5 by default) the information provided by theAccess-Control-Allow-Methods
andAccess-Control-Allow-Headers
headers can be cached.
Let’s go back to our previous example where we sent a complex request:
fetch(
'http://localhost:4000/greet',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ilija'})
}
).then(resp => resp.text()).then(console.log)
We already confirmed that when we send this request, our browser will check
with the server if it can perform the cross-origin request. To get this request
working in a cross-origin environment, we have to first add the OPTIONS /greet
endpoint to our server. In its response header, the new endpoint will
have to inform the browser that the request to POST /greet
, with
Content-type: application/json
header, from the origin
https://www.google.com
, can be accepted.
We’ll do this by using the Access-Control-Allow-*
headers:
options "/greet" do |env|
# Allow `POST /greet`...
env.response.headers["Access-Control-Allow-Methods"] = "POST"
# ...with `Content-type` header in the request...
env.response.headers["Access-Control-Allow-Headers"] = "Content-type"
# ...from https://www.google.com origin.
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
end
If we boot our server and send the request:
Our request remains blocked. Even though our OPTIONS /greet
endpoint did
allow the request, we are still seeing the error message. In our network tab
there’s something interesting going on:
The request to the OPTIONS /greet
endpoint was a success! But the POST /greet
call still failed. If we take a peek in the internals of the POST /greet
request we will see a familiar sight:
In fact, the request did succeed β the server returned a HTTP 200. The
preflight request did work β the browser did make the POST
request instead of
blocking it. But the response of the POST
request did not contain any CORS
headers, so even though the browser did make the request, it blocked any
response processing.
To allow the browser also process the response from the POST /greet
request,
we need to add a CORS header to the POST
endpoint as well:
post "/greet" do |env|
name = env.params.json["name"].as(String)
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
"Hello, #{name}!"
end
By adding the Access-Control-Allow-Origin
header response header, we tell the
browser that a tab that has https://www.google.com
open can also access the
response payload.
If we give this another shot:
We will see that POST /greet
did get us a response, without any errors. If
we take a peek in the Network tab, we’ll see that both requests are green:
By using proper response headers on our preflight endpoint OPTIONS /greet
, we
unlocked our server’s POST /greet
endpoint to be accessed across different
origin. On top of that, by providing a correct CORS response header on the
response of the POST /greet
endpoint, we freed the browser to process the
response without any blocking.
Cross-origin reads #
As we mentioned before, cross-origin reads are blocked by default. That’s on purpose - we wouldn’t want to load other resources from other origins in the scope of our origin.
Say, we have a GET /greet
action in our Crystal server:
get "/greet" do
"Hey!"
end
From our tab that has www.google.com
rendered, if we try to fetch
the GET /greet
endpoint we will get blocked by CORS:
If we look deeper in the request, we will found out something interesting:
In fact, just like before, our browser did let the request through βΒ we got a HTTP 200 back. But it did not expose our opened page / tab to the response of that request. Again, in this case CORS does not block the request - it blocks the response.
Just like with cross-origin writes, we can relax CORS and make it available for
cross-origin reading - by adding the Access-Control-Allow-Origin
header:
get "/greet" do |env|
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
"Hey!"
end
When the browser gets the response back from the server, it will look at the
Access-Control-Allow-Origin
header and will decide based on its value if it
can let the page read the response. Given that the value in this case is
https://www.google.com
which is the page that we use in our example the
outcome will be a success:
This is how the browser shields us from cross-origin reads and respects the server directives that are sent via the headers.
Fine-tuning CORS #
As we already saw in previous examples, to relax the CORS policy of our
website, we can set the Access-Control-Allow-Origin
of our /greet
action to
the https://www.google.com
value:
post "/greet" do |env|
body = env.request.body
name = "there"
name = body.gets.as(String) if !body.nil?
env.response.headers["Access-Control-Allow-Origin"] = "https://www.google.com"
"Hello, #{name}!"
end
This will allow the https://www.google.com
origin to call our server, and our
browser will feel fine about that. Having the Access-Control-Allow-Origin
in
place, we can try to execute the fetch
call again:
This made it work! With the new CORS policy, we can call our /greet
action
from our tab that has https://www.google.com
rendered. Alternatively, we
could also set the header value to *
, which would tell the browser that the
server can be called from any origin.
Such a configuration has to be carefully considered. Yet, putting relaxed CORS
headers is almost always safe. One rule of thumb is: if you open the URL in
an incognito tab, and you are happy with the information you are exposing, then
you can set a permissive (*
) CORS policy on said URL.
Another way to fine-tune CORS on our website is to use the
Access-Control-Allow-Credentials
response header.
Access-Control-Allow-Credentials
instructs browsers whether to expose the
response to the frontend JavaScript code when the request’s credentials mode is
include
.
The request’s credentials mode comes from the introduction of the Fetch
API, which has its roots back the original
XMLHttpRequest
objects:
var client = new XMLHttpRequest()
client.open("GET", "./")
client.withCredentials = true
With the introduction of fetch
, the withCredentials
option was transformed
into an optional argument to the fetch
call:
fetch("./", { credentials: "include" }).then(/* ... */)
The available options for the credentials
options are omit
, same-origin
and include
. The different modes are available so developers can fine-tune
the outbound request, whereas the response from the server will inform the
browser how to behave when credentials are sent with the request (via the
Access-Control-Allow-Credentials
header).
The Fetch API spec contains a well-written and thorough
breakdown of
the interplay of CORS and the fetch
Web API, and the security mechanisms put
in place by browsers.
Some best practices #
Before we wrap it up, let’s cover some best practices when it comes to Cross Origin Resource Sharing (CORS).
Free for all #
A common example is if you own a website that displays content for the public,
that is not behind paywalls, or requiring authentication or authorization βΒ you
should be able to set Access-Control-Allow-Origin: *
to its resources.
The *
value is a good choice in cases when:
- No authentication or authorization is required
- The resource should be accessible to a wide range of users without restrictions
- The origins & clients that will access the resource is of great variety, you don’t have knowledge of it or you simply don’t care
A dangerous prospect of such configuration is when it comes to content served on private networks (i.e. behind firewall or VPN). When you are connected via a VPN, you have access to the files on the company’s network:
Now, if an attacker hosts as website dangerous.com
, which contains a link to
a file within the VPN, they can (in theory) create a script on their website
that can access that file:
While such an attack is hard and requires a lot of knowledge about the VPN and the files stored within it, it is a potential attack vector that we must be aware of.
Keeping it in the family #
Continuing with the example from above, imagine we want to implement analytics for our website. We would like our users’ browsers to send us data about the experience and behavior of our users on our website.
A common way to do this is to send that data periodically using asynchronous requests using JavaScript in the browser. On the backend we have a simple API that takes these requests from our users’ browsers and stores the data on the backend for further processing.
In such cases, our API is public, but we don’t want any website to send data to our analytics API. In fact, we are interested only in requests that originate from browsers that have our website rendered βΒ that is all.
In such cases, we want our API to set the Access-Control-Allow-Origin
header
to our website’s URL. That will make sure browsers never send requests to our
API from other pages.
If users or other websites try to cram data in our analytics API, the
Access-Control-Allow-Origin
headers set on the resources of our API won’t let
the request to go through:
NULL origins #
Another interesting case are null
origins. They occur when a resource is
accessed by a browser that renders a local file. For example, requests coming
from some JavaScript running in a static file on your local machine have the
Origin
header set to null
.
In such cases, if our servers do now allow access to resources for the null
origin, then it can be a hindrance to the developer productivity. Allowing the
null
origin within your CORS policy has to be deliberately done, and only if
the users of your website / product are developers.
Skip cookies, if you can #
As we saw before with the Access-Control-Allow-Credentials
, cookies are not
enabled by default. To allow cross-origin sending cookies, it as easy as
returning Access-Control-Allow-Credentials: true
. This header will tell
browsers that they are allowed to send credentials (i.e. cookies) in
cross-origin requests.
Allowing and accepting cross-origin cookies can be tricky. You could expose yourself to potential attack vectors, so enable them only when absolutely necessary.
Cross-origin cookies work best in situations when you know exactly which
clients will be accessing your server. That is why the CORS semantics do not
allow us to set Access-Control-Allow-Origin: *
when cross-origin credentials
are allowed.
While the Access-Control-Allow-Origin: *
and
Access-Control-Allow-Credentials: true
combination is technically allowed,
it’s a anti-pattern and should absolutely be avoided.
If you would like your servers to be accessed by different clients and origins, you should probably look into building an API (with token-based authentication) instead of using cookies. But if going down the API path is not an option, then make sure you implement cross-site request forgery (CSRF) protection.
Additional reading #
I hope this (long) read gave you a good idea about CORS, how it came to be, and why it’s neccesary. Here are a few more links that I used while writing this article, or that I believe are a good read on the topic:
- Cross-Origin Resource Sharing (CORS)
Access-Control-Allow-Credentials
header on MDN Web Docs- Authoritative guide to CORS (Cross-Origin Resource Sharing) for REST APIs
- The “CORS protocol” section of the Fetch API spec
- Same-origin policy on MDN Web Docs
- Quentin’s great summary of CORS on StackOverflow