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:

  • fetch or XMLHttpRequest responses

  • 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:

  1. You log in to https://bank.example.com in one tab

  2. The bank sets a session cookie and keeps you logged in

  3. In another tab, you visit https://evil.example.org

  4. evil.example.org runs 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.org read 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 JSON

and in the browser:

await fetch('https://api.example.com/users')
// CORS error in the console

Same 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, Authorization

The 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: 600

If 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.

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:

  1. Forward the Origin header to your application

    If the edge drops the Origin header, your backend cannot make per origin decisions. Many CORS libraries rely on inspecting Origin to decide what to put in Access-Control-Allow-Origin.

  2. Forward preflight headers

    The browser sends OPTIONS requests with Access-Control-Request-Method and Access-Control-Request-Headers.

    Your proxy needs to:

    • Route OPTIONS to 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.

  3. Do not blindly overwrite Access-Control-Allow-* from upstream

    Sometimes 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.

  4. Remember Vary: Origin when you return per origin headers

    If you dynamically echo the Origin header in Access-Control-Allow-Origin for more than one specific origin, add Vary: Origin so 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 Origin header; only echo it back if the origin is included in a strict allow-list. This is not like returning a wildcard, but much worse. When Access-Control-Allow-Origin is naively set to the request’s Origin value and Access-Control-Allow-Credentials: true is 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

  • Authorization headers

  • 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:

  1. Explicitly opt in to credentials:

    Access-Control-Allow-Credentials: true
  2. Do not use * in Access-Control-Allow-Origin.

    When Access-Control-Allow-Credentials: true is 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: Origin

Typical server side pattern:

  • Read the Origin header

  • 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.

Cookies are attached to domains, not origins.

  • If your backend sets a cookie without a Domain attribute from api.example.com, it becomes a host only cookie and is sent only to api.example.com.

  • If it sets Domain=.example.com, the browser will send that cookie to api.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=Lax or Strict will send cookies mainly in first party or top navigation contexts

  • SameSite=None; Secure is 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 and Access-Control-Allow-Credentials: true + explicit Access-Control-Allow-Origin on the server

If cookies are not being sent, check this order:

  1. Is the cookie actually set for the domain you expect (Domain attribute and Secure flag)?

  2. Is the browser sending it on the network request (check devtools network panel)?

  3. Is the request using credentials: 'include' or an equivalent option in your HTTP client?

  4. 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:

  1. The SOP is the core rule: no cross origin reads unless explicitly allowed

  2. CORS is a system that lets servers selectively relax the SOP

  3. Only browsers care about any of this; other HTTP clients like curl and Postman do not

  4. Forwarding Origin, handling preflights, and setting the right response headers is an end to end responsibility

  5. 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.

Resources