
Published on:
A Complete Overview of Same-Origin Policy (SOP) and Cross-Origin Resource Sharing (CORS)
If you've spent enough time developing web applications, you've certainly come across this error:
Access to fetch at 'https://api.example.com' from origin 'https://app.yoursaas.com' has been blocked by CORS
Sound familiar?
Most likely, you've encountered it when your backend and frontend are hosted on separate servers, and the frontend tries to communicate with the backend.
This error occurs because of a browser-enforced security mechanism called the Same-Origin Policy (SOP), which blocks JavaScript access to the response body of cross-origin HTTP requests by default.
To bypass that restriction, your server needs to explicitly allow such access by responding with the appropriate Cross-Origin Resource Sharing (CORS) headers.
Both SOP and CORS are designed to protect users by preventing unauthorized access to resources and sensitive data between different origins. As a developer, understanding how they work together is essential to building secure and reliable web applications. But first:
What Is an "Origin"?
An origin is defined by three things:
- Protocol (
http
,https
) - Domain name (
example.com
) - Port (
:80
,:443
, etc.)
The combination of these three: protocol, domain, and port, defines an origin.
If any of those differ, the origin is not the same:
https://app.example.com ≠ http://app.example.com
https://app.example.com ≠ https://api.example.com
https://example.com:3000 ≠ https://example.com
So, now that we made it clear what an origin is, we can get to the meat of this post.
What Is the Same-Origin Policy (SOP)?
According to MDN:
Same-Origin Policy is a critical security mechanism that restricts how a document or script loaded by one origin can interact with a resource from another origin.
Source: MDN Web Docs, licensed under CC-BY-SA 2.5.
Same-Origin Policy is one of the cornerstones of web security. It protects your users by stopping potentially malicious websites from interacting with your site's data behind their backs.
In plain English: browsers allow web pages to send requests to different origins, but block JavaScript from accessing the response, unless the server explicitly allows it via Cross-Origin Resource Sharing.
Many developers mistakenly believe that the Same-Origin Policy blocks cross-origin requests. In truth, it doesn't prevent the requests themselves, they're still sent and can be seen as successful in the browser’s network tab. What SOP actually restricts is JavaScript’s ability to read the response content from those requests.
This brings up another common point of confusion when learning about SOP and CORS:
How Are We Able to Load Images and Access Links from Different Origins?
Some cross-origin requests are allowed by design. Here are the main exceptions:
<a href="https://example.com">
: Linking to other sites is allowed.<img src="https://cdn.example.com/logo.png">
: Images can be loaded cross-origin.<script src="https://unpkg.com/vue">
: Scripts from CDNs are permitted (but restricted via CSP).- Form submissions (
<form action="https://api.example.com">
) are also allowed.
What all these have in common is that they don’t give JavaScript access to the response body, and that’s the key.
In contrast, when you use something like fetch()
or XMLHttpRequest
, you're trying to read the response data directly from another origin, and that’s where SOP draws the line.
So while some cross-origin requests are allowed for functionality or rendering, the browser keeps a strict boundary: if your JavaScript wants to interact with the response data, you’ll need permission. This is where CORS steps in.
So how do we get cross-origin data access when we actually need it?
Enter CORS: Cross-Origin Resource Sharing
So what happens if your frontend needs to talk to another origin, like an API hosted elsewhere?
That’s where CORS comes in.
Cross-Origin Resource Sharing is a security standard that tells browsers what cross-origin requests are allowed. Basically, it's an opt-in exception mechanism for SOP.
It’s implemented on the server-side, and it tells the browser which domains are allowed to access its resources.
This is how it works: the server responds along with specific CORS headers, and based on the values those headers hold, the browser decides whether to grant JavaScript access to the response body or block it.
The server doesn't block or permit anything, that's entirely up to the browser. The server can only influence the browser's decision by setting the right CORS header values.
A Simple CORS Example
Let’s say you’re trying to fetch user data from your API with a simple HTTP GET
request:
fetch("https://backend.com/some-api")
.then(res => res.json())
.then(data => console.log(data));
When the browser detects that the request is going to a different origin, it sends it and waits for the server’s response. Then it checks whether the response from the server includes the Access-Control-Allow-Origin
header. If the header is missing or doesn't allow the requesting origin, the browser blocks JavaScript from accessing the response.
Access-Control-Allow-Origin
is a CORS header that specifies which remote origin is allowed to make requests to the server. Its value can be a wildcard *
, which means all origins are allowed (not recommended for security purposes), or a specific domain. To support multiple domains, you'll need server-side logic to dynamically set the value for the header.
If Access-Control-Allow-Origin
is absent, or its value does not match the origin that made the request, the browser will step in and JavaScript won't be able to read response data thanks to Same-Origin Policy.
Here is a sample of the response headers of our previous API request to fetch user data:
Access-Control-Allow-Origin: https://frontend.com
In our case, Access-Control-Allow-Origin
header tells us that only the origin https://frontend.com
is allowed to make a cross-origin request to the server, which is the origin of our frontend, so we can safely get access to the response, and no errors are raised.
About "Simple Requests" and "Complex Requests"
Before we move on, there's one key nuance to understand: browsers classify HTTP requests as either "simple" or "complex".
For a request to be considered "simple", it has to meet the following criteria:
- Only
GET
,HEAD
, orPOST
method. - No custom headers, such as
Authorization
,X-Custom-Header
, orAccept-Encoding
. OnlyAccept
,Accept-Language
,Content-Language
, andContent-Type
(with the allowed values, see next criteria). - A
Content-Type
header value oftext/plain
,multipart/form-data
,application/x-www-form-urlencoded
.
If the request doesn't meet any of the above conditions, the browser will consider it "complex". For example, an HTTP PUT
request, or a POST
request with a Content-Type: application/json
header.
In the previous example, the request is a GET
with no custom headers, and no Content-Type
, so it qualifies as a simple request.
Why Do We Need This Distinction?
For simple requests, the browser only looks for the Access-Control-Allow-Origin
header in the server’s response. If it's present and valid, the browser grants JavaScript access to the response body. That’s exactly what happens in our earlier example.
Complex requests, however, require more scrutiny. The browser expects additional CORS headers, and before it even sends the actual request, it first issues an automatic preflight request, a separate OPTIONS
request, to check if the real request is allowed. Only if the server responds correctly to the preflight will the browser proceed with the actual request.
So far, we only talked about "simple" requests, but for complex requests, a preflight request is necessary before anything else happens.
So far, we've only been dealing with simple requests. But when it comes to complex requests, things change; the browser must first send a preflight request to verify that the server permits the intended action before the actual request is sent.
What Does a Preflight Request Look Like?
Like this:
OPTIONS /user HTTP/1.1
Origin: https://frontend.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization
These two headers, Access-Control-Request-Method
, and Access-Control-Request-Headers
, play a key role in informing the server of what to expect. I explain both in detail in my other post about preflight requests.
Then the server must respond with headers that explicitly allow the request:
Access-Control-Allow-Origin: https://frontend.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization
Only then does the browser proceed with the actual request.
If the server provides a valid response, the browser proceeds with the actual request. However, if any of these headers are missing or incorrect, the browser will block the request before it even happens.
Even after a successful preflight, the actual response must include the appropriate CORS headers, otherwise, the Same-Origin Policy kicks in again and prevents JavaScript from accessing the response body.
Two Key Takeaways
- Preflight requests are part of CORS, not SOP. If the server doesn’t respond correctly to a preflight
OPTIONS request
, the actual request never gets sent. There's no role for SOP at this point; since there's no response, the browser doesn’t need to block access to anything. - For complex requests, the server must respond with additional headers, not just
Access-Control-Allow-Origin
. These includeAccess-Control-Allow-Methods
,Access-Control-Allow-Headers
. I already explained them in great detail in my other post about preflight requests, which I highly recommend you take a look at.
Why This Matters
This extra preflight step adds security and transparency.
- Servers can control who is allowed to access them.
- They can limit what methods and headers are accepted.
- They can block certain apps entirely by omitting CORS headers.
For example, if someone tries to call your API from a different app you don’t trust, the request will be blocked entirely. This is possible because the browser automatically includes an Origin
header in cross-origin requests, letting the server know where the request came from. Based on that origin, the server can decide whether to respond with the necessary CORS headers or not.
What's the Point of Preflight Requests if SOP Already Exists?
Simply put, because SOP alone is not enough.
SOP doesn't stop the request from taking place, it only blocks JavaScript from reading the response if it's cross-origin without proper CORS headers.
But preflight requests happen before the real request is sent if the browser deems it potentially "unsafe or complex", like if the request is using custom headers, methods like PUT
or DELETE
. They are basically the browser asking the server for permission to send the actual request.
SOP and preflight requests offer different layers of protection. Let me illustrate that with an example:
Imagine this scenario:
fetch('https://backend.com/delete-account', {
method: 'DELETE',
});
Without CORS preflight, the browser could send the actual DELETE
request, and even though JavaScript couldn't read the response due to SOP, the damage is already done, the account is deleted.
SOP only protects the response, not the request.
3 Important Things to Keep in Mind
1. Don’t Use *
in Production
Using Access-Control-Allow-Origin: *
allows any site to access your API. It's okay for public, read-only APIs, but bad for authenticated or sensitive routes.
Be specific in production to avoid security risks.
2. Add Access-Control-Allow-Credentials: true
With credentials: 'include'
If you're going to send cookies along with the request like this:
fetch("https://backend.com/post-api", {
credentials: "include"
})
Then your backend must include Access-Control-Allow-Credentials: true
in its response. Otherwise, the browser will block the response, even if the origin is allowed.
Also, you must be specific when declaring the allowed origin in Access-Control-Allow-Origin
, which means *
is no longer accepted as a value. The browser will only accept a response with a Access-Control-Allow-Origin
set to one specific origin if the request is sent with credentials.
3. SOP and CORS Only Protect the Browser
Same-Origin Policy and CORS are browser-enforced security mechanisms. They do not protect your server from malicious requests made by tools like curl
, Postman, or custom scripts.
To fully secure your APIs, you need server-side protections like:
- Authentication & authorization checks.
- API key validation.
- Throttling & rate limiting.
- Input validation & sanitization.
Common CORS Errors (and What They Mean)
I believe that after reading so far, you can guess the meaning of any CORS error just by reading the error message. Let's walk through some of the most common ones:
Reason: CORS header 'Access-Control-Allow-Origin' missing
It means that the Access-Control-Allow-Origin
header is completely absent in the response. The server did not include it at all. You need to configure your server to include the header in its responses.
Reason: CORS header 'Access-Control-Allow-Origin' does not match 'https://frontend.com'
The origin "https://frontend.com" is not permitted to make cross-origin requests to the server. To fix this, make "https://frontend.com" the value for Access-Control-Allow-Origin
header in the server.
Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*'
You attempted to make a request with credentials (cookies, HTTP authentication headers), but Access-Control-Allow-Origin
is set to *
, which prohibits using credentials. To fix this, either omit credentials: "include"
if you're working with fetch()
, or configure a specific origin as the value of Access-Control-Allow-Origin
.
Reason: Did not find method in CORS header 'Access-Control-Allow-Methods'
You sent a cross-origin request with a method that is not supported by the server. For example, you made a DELETE
request, but the response returned included Access-Control-Allow-Methods: GET, POST, HEAD, PATCH
. As you can see, DELETE
is not one of the methods permitted in cross-origin requests.
Reason: Multiple CORS header 'Access-Control-Allow-Origin' not allowed
This happens when the server responds with more than one Access-Control-Allow-Origin
header or a single header with a comma-separated list of origins, which browsers do not accept. You must return only one origin as the value, or use the wildcard *
to allow all origins (to be avoided in production).
To set multiple values, you need to do it dynamically. Create an array of allowed origins, and check the Origin
header of the request. If it's one of the allowed origins, set it as the value of Access-Control-Allow-Origin
.
How to Fix CORS (in express.js)
You fix CORS on the server, not the frontend.
I recommend using cors
library to handle Cross-Origin Resource Sharing. First, you need to install via NPM, or your preferred package manager:
npm install cors
And now you can use it as a middleware in your code:
const cors = require("cors");
app.use(cors({ origin: "https://frontend.com" }));
cors
library already handles OPTIONS
requests out of the box.
Other configuration options for the cors
middleware include:
methods
: sets theAccess-Control-Allow-Methods
header. It accepts either a comma-delimited string"GET,PUT,POST"
, or an array like["GET", "PUT", "POST"]
.credentials
: sets theAccess-Control-Allow-Credentials
header. If set totrue
, the header will be passed, otherwise it'll be omitted.allowedHeaders
: sets theAccess-Control-Allow-Headers
header. It accepts either a comma-delimited string"Content-Type,Authorization"
, or an array like["Content-Type", "Authorization"]
.
Brief Summary On How Browsers Handle Cross-Origin Requests
For Simple Requests: if the request uses GET
, POST
, or HEAD
, with no custom headers and a safe Content-Type
, the browser sends the request immediately. It then checks the response for a valid Access-Control-Allow-Origin
header. If present and correct, JavaScript is granted access to the response. No preflight needed.
For Complex Requests: if the request includes custom headers (like Authorization
), uses methods like PUT
or DELETE
, or has a non-standard Content-Type
, the browser sends a preflight OPTIONS
request first. This request includes Access-Control-Request-Method
and Access-Control-Request-Headers
headers (both explained in this article about preflight requests). If the server responds with the correct Access-Control-Allow-*
headers, the browser proceeds with the actual request. Otherwise, it blocks it.