
Have you ever built a front-end application that calls one or more APIs? Then you’ve certainly been here…
Plot twist: just like Anakin, you probably don’t hate CORS.
What you actually keep bumping into is the Same-Origin Policy (SOP). CORS is the tool that lets you poke carefully controlled holes in that wall. It is more like the safety door than the prison.
What is the Same-Origin Policy (SOP)?
The Same-Origin Policy is a browser security rule that says:
Scripts from origin A are not allowed to freely read data from origin B.
An origin is defined by the holy trinity:
-
scheme (http or https in practice)
-
host (example.com, api.example.com, localhost)
-
port (80, 443, 3000, 8080, …)
Two URLs have the same origin only if all three match.
Examples:
-
https://app.example.com&https://app.example.com/path→ same origin
-
https://app.example.com&https://api.example.com→ different origin (host changes)
-
http://app.example.com&https://app.example.com→ different origin (scheme changes)
-
https://app.example.com&https://app.example.com:8443→ different origin (port changes)
The SOP is enforced by the browser for things like:
-
fetchorXMLHttpRequestresponses -
Accessing another window or iframe’s DOM
-
Accessing cross origin storage like
localStorage
It does not stop the browser from making the network request. The browser will happily send the HTTP request to the other origin. The SOP is about what JavaScript is allowed to read from the response.
If the request violates the SOP and there is no explicit permission, the browser simply hides the response from your JavaScript and surfaces a CORS error in the console.
What is the SOP protecting you from?
The classic mental model:
-
You log in to
https://bank.example.comin one tab -
The bank sets a session cookie and keeps you logged in
-
In another tab, you visit
https://evil.example.org -
evil.example.orgruns JavaScript like:fetch('https://bank.example.com/api/transactions') .then(r => r.json()) .then(console.log)
Without the SOP, the browser would:
-
Send your bank session cookie with that request (because the domain matches)
-
Let the page at
evil.example.orgread the full JSON response, including balances, account numbers, etc.
The SOP prevents step 4 from succeeding. The request might still go out, but the response is considered opaque to that script unless the bank explicitly opts in using CORS and says “I trust this origin”.
So the SOP helps against:
-
Cross site data stealing using your existing cookies or browser state
-
Some classes of cross site scripting chain attacks
Note that the SOP is not a defense against CSRF on its own, because the browser can still send write requests, but it is a core building block of the browser security model.
What is CORS?
(…baby don’t hurt me 🎵)
Cross-Origin Resource Sharing (CORS) is a system based on HTTP headers that allows a server to say:
“It is fine for JavaScript running on origin X to read this response.”
The basic players are:
-
Request header:
Origin: https://app.example.com -
Response header:
Access-Control-Allow-Origin: https://app.example.com
When the browser sees those two match, it allows your JavaScript to read the response body, headers, and so on.
So CORS is not a separate security model. It is simply the negotiated exception system for the Same-Origin Policy.
CORS vs SOP - round 2
When you see this in your devtools:
Access to fetch at ‘https://api.example.com/users’ from origin ‘http://localhost:3000’ has been blocked by CORS policy…
It is tempting to read:
“CORS is blocking my request.”
Reality:
-
The Same-Origin Policy is blocking your JavaScript from reading the response
-
The browser labels it as a “CORS” issue because the way you bypass the SOP is by configuring CORS
If the SOP did not exist, this would work without any headers at all.
But then any random website you visit could silently make authenticated requests to your bank, email, or internal admin panels and read all the responses, just because the browser automatically sends cookies.
CORS is the mechanism that lets a backend say “I explicitly allow this cross origin read” in a granular way. It is your ally if you want to expose an API to web frontends across different origins.
The SOP is only enforced by browsers
Here is the important distinction:
-
The SOP and CORS are implemented by browsers
-
They are not part of HTTP itself
If you run the same request with curl, Postman, the http library in your programming language, or any other http client which is not a browser, they:
-
Will not care about origins
-
Will not perform CORS preflights (more on these below)
-
Will not block responses based on
Access-Control-Allow-*headers
So you might see this:
curl https://api.example.com/users
# works fine, you see JSONand in the browser:
await fetch('https://api.example.com/users')
// CORS error in the consoleSame server, same endpoint, but different client behavior.
This is why you cannot rely on CORS as a server side security control. To actually protect the API you still need:
-
Authentication and authorization
-
Rate limiting and abuse detection
-
Input validation
The SOP and CORS only limit what browser based JavaScript can do.
Preflight checks
Before certain cross-origin requests, browsers automatically send what’s known as a preflight request (using the OPTIONS HTTP method), to check with the server whether the actual request is allowed. Preflight requests are sent before those that use non-simple methods (any other than GET, POST, and HEAD), custom headers, or even custom methods.
Here’s a preflight request example:
OPTIONS /api/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, AuthorizationThe server then replies with what it is willing to allow:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600If the preflight says “yes”, the browser sends the actual request and, assuming the final response also has a compatible Access-Control-Allow-Origin, the JS code gets access to the response.
Forwarding CORS related headers downstream
In real systems you often do not expose your app server directly to the internet. You have:
-
A reverse proxy (Nginx, Apache, Envoy)
-
An API gateway
-
A load balancer or edge function
These layers can easily break CORS if they do not handle the relevant headers correctly.
Things to watch out for:
-
Forward the
Originheader to your applicationIf the edge drops the
Originheader, your backend cannot make per origin decisions. Many CORS libraries rely on inspectingOriginto decide what to put inAccess-Control-Allow-Origin. -
Forward preflight headers
The browser sends
OPTIONSrequests withAccess-Control-Request-MethodandAccess-Control-Request-Headers.Your proxy needs to:
-
Route
OPTIONSto something that knows how to answer them, or -
Handle them itself in a generic way and add the right
Access-Control-Allow-*headers
Dropping preflight headers or sending incomplete responses is a common source of CORS failures that can be hard to troubleshoot, especially when chaining many (reverse, in most cases) proxies.
-
-
Do not blindly overwrite
Access-Control-Allow-*from upstreamSometimes an upstream service, already sets CORS headers, e.g., an HA Express.js API running in a few containers behind Nginx acting as a load balancer and reverse proxy. If Nginx also sets them, you can end up with conflicting or overwritten headers.
Decide where CORS is enforced and make that layer authoritative.
-
Remember
Vary: Originwhen you return per origin headersIf you dynamically echo the
Originheader inAccess-Control-Allow-Originfor more than one specific origin, addVary: Originso that caching proxies and CDNs do not accidentally serve a response allowed for Origin A to Origin B.⚠️ NEVER reflect whatever value you receive in the
Originheader; only echo it back if the origin is included in a strict allow-list. This is not like returning a wildcard, but much worse. WhenAccess-Control-Allow-Originis naively set to the request’sOriginvalue andAccess-Control-Allow-Credentials: trueis enabled, every origin effectively becomes a trusted client that can send credentialed requests. This means that an attacker who exploits XSS on any allowed origin can leverage the victim’s browser to fully compromise their account through your API.
In other words: CORS is an end to end contract. Every hop between browser and application has to respect and forward the relevant headers.
Allowing credentials: you cannot use a wildcard
Sometimes you do want the browser to send and receive credentials in a cross origin request:
-
Cookies
-
Authorizationheaders -
TLS client certificates
On the frontend you typically enable this with:
fetch('https://api.example.com/me', {
credentials: 'include',
})On the server side you have to do two important things:
-
Explicitly opt in to credentials:
Access-Control-Allow-Credentials: true -
Do not use
*inAccess-Control-Allow-Origin.When
Access-Control-Allow-Credentials: trueis present, the browser refuses a wildcard origin.
Instead, you have to return a specific origin:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: OriginTypical server side pattern:
-
Read the
Originheader -
Check it against an allowlist
-
Echo it back if it is allowed
Example:
const allowed = ['https://app.example.com', 'https://admin.example.com'];
function corsHeadersFor(origin) {
if (!allowed.includes(origin)) return {};
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Vary': 'Origin',
};
}If you try to combine Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *, modern browsers will block the response and show a helpful error message.
A word about cookies
Are cookies sent across subdomains? Short answer: they can be, but only if you configure them that way, and CORS controls whether JavaScript can read the response, not whether the cookie is sent.
There are a few things to consider here.
1. Cookie domain vs origin
Cookies are attached to domains, not origins.
-
If your backend sets a cookie without a
Domainattribute fromapi.example.com, it becomes a host only cookie and is sent only toapi.example.com. -
If it sets
Domain=.example.com, the browser will send that cookie toapi.example.com,app.example.com,admin.example.com, and so on.
This is independent of the SOP: cross-subdomain means cross-origin.
2. SameSite and first party vs third party
Modern browsers also consider whether a request is first party (going to the same site) or third party (to another site) when deciding to send cookies, using the SameSite attribute:
-
SameSite=LaxorStrictwill send cookies mainly in first party or top navigation contexts -
SameSite=None; Secureis required if you want cookies to be sent in more cross site scenarios, including some iframe and embedded use cases
From the browser’s perspective, app.example.com calling api.example.com is usually considered the same site, so SameSite=Lax cookies with Domain=.example.com will typically be sent.
3. CORS and cookies together
When you use cookies for auth between subdomains, you need both:
-
Cookie configured for the right domain, path, SameSite and Secure attributes
-
CORS configured with
credentials: 'include'on the client andAccess-Control-Allow-Credentials: true+ explicitAccess-Control-Allow-Originon the server
If cookies are not being sent, check this order:
-
Is the cookie actually set for the domain you expect (
Domainattribute andSecureflag)? -
Is the browser sending it on the network request (check devtools network panel)?
-
Is the request using
credentials: 'include'or an equivalent option in your HTTP client? -
Is the response satisfying CORS rules so that your JS can see it, or is the browser hiding it behind a CORS error?
TL;DR
When you see a CORS error in the console, remember the layers involved:
-
The SOP is the core rule: no cross origin reads unless explicitly allowed
-
CORS is a system that lets servers selectively relax the SOP
-
Only browsers care about any of this; other HTTP clients like curl and Postman do not
-
Forwarding
Origin, handling preflights, and setting the right response headers is an end to end responsibility -
Credentials and cookies bring additional security measures and resulting challenges: you can’t combine wildcard origins with credentials, and you must be intentional about cookie domains and SameSite
You probably do not want to “disable CORS”. You want to understand the SOP, configure CORS intentionally, and then let the browser enforce the rules on your behalf.