Skip to content

Same-Origin Policy and CORS

intermediate18 min read

The Wall You Did Not Know Existed

You have probably seen this error in your console:

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

And you probably Googled the error, added Access-Control-Allow-Origin: * to your server, and moved on. That works. But you just poked a hole in one of the most important security boundaries on the web without understanding what it protects or what you exposed.

Let's fix that.

Mental Model

Think of the browser as an apartment building with strict security. Each apartment (origin) has its own locked door. Tenants inside one apartment can do whatever they want with their own stuff. But if a tenant in apartment A wants to borrow something from apartment B, the security guard (browser) checks whether apartment B left a note on their door saying "apartment A is allowed in." Without that note, the guard blocks entry. The note is CORS. The locked-door policy is the Same-Origin Policy.

What Is an "Origin"?

An origin is defined by three parts: scheme (protocol), host (domain), and port. All three must match exactly for two URLs to be considered same-origin.

https://example.com:443/path/page
|_____|  |__________|___|
scheme      host     port
URL AURL BSame origin?Why
https://example.comhttps://example.com/aboutYesPath does not matter
https://example.comhttp://example.comNoDifferent scheme
https://example.comhttps://api.example.comNoDifferent host (subdomain counts)
https://example.comhttps://example.com:8080NoDifferent port
https://example.com:443https://example.comYes443 is the default HTTPS port

The port comparison is the one that trips people up most. https://example.com and https://example.com:443 are the same origin because 443 is the default port for HTTPS. But http://example.com (port 80) and http://example.com:3000 are different origins.

Quiz
Which pair of URLs share the same origin?

The Same-Origin Policy

The Same-Origin Policy (SOP) is a browser security mechanism that restricts how a document or script from one origin can interact with resources from another origin. It has been part of browsers since Netscape 2.0 in 1995.

What SOP Blocks

  • Reading responses from cross-origin fetch / XMLHttpRequest calls
  • Accessing the DOM of a cross-origin iframe
  • Reading cross-origin canvas data after drawing external images
  • Reading cross-origin localStorage or sessionStorage

What SOP Allows

This is the part that surprises people. SOP does not block everything cross-origin:

  • Embedding cross-origin resources: images via img, scripts via script, stylesheets via link, media via video/audio, iframes via iframe
  • Sending cross-origin form submissions (the browser sends the request, you just cannot read the response)
  • Sending cross-origin requests via fetch or XMLHttpRequest (the request leaves the browser, but the response is blocked unless CORS headers allow it)

That last point is critical. The request still reaches the server. SOP blocks the response from being read by your JavaScript, not the request from being sent. This is a common misconception.

Common Trap

SOP does not prevent the request from being sent to the server. It prevents the browser from exposing the response to your JavaScript. If a cross-origin POST request has side effects on the server (like deleting data), that side effect still happens even though the browser blocks your code from reading the response. This is why CSRF protection exists as a separate concern.

Quiz
A script on https://myapp.com uses fetch to POST data to https://api.other.com without CORS headers. What happens?

Enter CORS

CORS (Cross-Origin Resource Sharing) is the mechanism that lets servers opt into allowing cross-origin access. It is not a security feature you add to your frontend. It is a set of HTTP headers the server sends to tell the browser: "I am okay with this origin reading my responses."

Simple Requests

Not every cross-origin request triggers CORS preflight. A request is "simple" if it meets all of these conditions:

  • Method is GET, HEAD, or POST
  • Headers are only the CORS-safelisted ones: Accept, Accept-Language, Content-Language, Content-Type (with value limited to application/x-www-form-urlencoded, multipart/form-data, or text/plain)
  • No ReadableStream body
  • No event listeners on XMLHttpRequest.upload

For a simple request, the browser sends it directly and checks the response for the Access-Control-Allow-Origin header:

GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Content-Type: application/json

{"data": "here"}

If the Access-Control-Allow-Origin header matches the requesting origin (or is *), the browser lets your JavaScript read the response. If not, the response is blocked.

Preflighted Requests

If a request is not "simple" (uses PUT, DELETE, PATCH, sends Content-Type: application/json, includes custom headers like Authorization), the browser sends a preflight request first.

The preflight is an OPTIONS request that asks the server: "Would you accept the real request I am about to send?"

Here is what the preflight exchange looks like on the wire:

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://myapp.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, PUT, POST, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

The Access-Control-Max-Age header tells the browser to cache this preflight result for 86400 seconds (24 hours). Without it, the browser sends a preflight before every single non-simple request to the same endpoint, which adds latency.

Quiz
Which request triggers a CORS preflight?

CORS Headers Deep Dive

Response Headers (Server → Browser)

HeaderPurposeExample
Access-Control-Allow-OriginWhich origin can read the responsehttps://myapp.com or *
Access-Control-Allow-MethodsWhich HTTP methods are allowedGET, POST, PUT, DELETE
Access-Control-Allow-HeadersWhich request headers are allowedContent-Type, Authorization
Access-Control-Expose-HeadersWhich response headers JS can readX-Request-Id, X-RateLimit-Remaining
Access-Control-Max-AgeHow long to cache preflight (seconds)86400
Access-Control-Allow-CredentialsAllow cookies/auth headerstrue

The Credentials Trap

By default, cross-origin fetch does not send cookies. You need both sides to opt in:

// Client: include credentials
fetch("https://api.example.com/data", {
  credentials: "include"
});
// Server: allow credentials
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: true

Here is the rule that catches everyone: when Access-Control-Allow-Credentials: true is set, Access-Control-Allow-Origin cannot be *. You must specify the exact origin. This is a deliberate security constraint. Allowing credentials from any origin would let any website make authenticated requests on behalf of your users.

Why wildcard plus credentials is banned

Imagine you are logged into your bank at https://bank.com. If bank.com responded with Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true, then any website you visit could use fetch with credentials: "include" to make requests to bank.com using your session cookies. The evil site could read your balance, initiate transfers, anything. By forcing the server to name a specific origin when credentials are involved, the browser ensures the server is making a conscious decision about which site to trust.

Exposing Custom Response Headers

By default, JavaScript can only read a limited set of response headers from cross-origin responses (the "CORS-safelisted response headers"): Cache-Control, Content-Language, Content-Length, Content-Type, Expires, and Pragma.

If your API returns custom headers like X-Request-Id or X-RateLimit-Remaining and your frontend needs to read them, the server must explicitly expose them:

Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining

Without this, response.headers.get("X-Request-Id") returns null even though the header exists in the response.

Common CORS Errors and How to Fix Them

Error 1: No Access-Control-Allow-Origin header

Access to fetch at '...' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the
requested resource.

Cause: The server is not sending CORS headers at all.

Fix: Add the header on the server. In Express:

app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "https://myapp.com");
  next();
});

Error 2: Preflight response is not OK

Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.

Cause: The server is not handling OPTIONS requests, or is returning a non-2xx status for them.

Fix: Handle OPTIONS explicitly:

app.options("/api/*", (req, res) => {
  res.header("Access-Control-Allow-Origin", "https://myapp.com");
  res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
  res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
  res.sendStatus(204);
});

Error 3: Wildcard with credentials

The value of the 'Access-Control-Allow-Origin' header in the response
must not be the wildcard '*' when the request's credentials mode is 'include'.

Cause: Server sends Access-Control-Allow-Origin: * but the request uses credentials: "include".

Fix: Replace the wildcard with the specific origin. If you need to support multiple origins, read the Origin request header and echo it back after validating it against an allowlist:

const allowedOrigins = [
  "https://myapp.com",
  "https://staging.myapp.com"
];

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.header("Access-Control-Allow-Origin", origin);
    res.header("Access-Control-Allow-Credentials", "true");
    res.header("Vary", "Origin");
  }
  next();
});

Notice the Vary: Origin header. This tells caches (CDNs, proxies) that the response varies based on the Origin request header. Without it, a cached response for one origin could be served to another.

Error 4: Header not allowed

Request header field authorization is not allowed by
Access-Control-Allow-Headers in preflight response.

Cause: The preflight response does not include the custom header in Access-Control-Allow-Headers.

Fix: Add the missing header name to Access-Control-Allow-Headers.

Quiz
Your API sets Access-Control-Allow-Origin: * and your frontend uses fetch with credentials: include. What happens?

Proxy Patterns for Development

During development, your frontend at http://localhost:3000 often needs to talk to an API at http://localhost:8080 or a remote server. Instead of configuring CORS on the API (which you might not control), you can proxy requests through your dev server.

Next.js Rewrites

// next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: "/api/:path*",
        destination: "https://api.example.com/:path*"
      }
    ];
  }
};

Your frontend calls /api/data and the dev server proxies it to https://api.example.com/data. Since the browser sees the request going to the same origin, no CORS is involved.

Vite Proxy

// vite.config.js
export default {
  server: {
    proxy: {
      "/api": {
        target: "https://api.example.com",
        changeOrigin: true
      }
    }
  }
};

Why Proxies Work

The proxy works because it moves the cross-origin request from the browser to the server. Your browser talks to localhost:3000, which is same-origin. The dev server then makes the request to the API server. Server-to-server requests are not subject to SOP because SOP is a browser security policy. Servers do not have it.

Common Trap

Dev proxies solve CORS in development only. In production, you need a real solution: either configure CORS on your API server, or deploy a reverse proxy (nginx, Cloudflare Workers, API Gateway) that sits in front of your API on the same origin as your frontend.

The Danger of Access-Control-Allow-Origin: *

Setting Access-Control-Allow-Origin: * is safe in specific situations and dangerous in others.

When the Wildcard Is Safe

  • Public, read-only APIs that serve non-sensitive data (weather data, open datasets, public CDN resources)
  • Static assets (fonts, images, public JS/CSS files)
  • Responses that do not vary by user and contain no sensitive information

When the Wildcard Is Dangerous

The wildcard becomes a security problem when:

  • The response contains user-specific data (profile info, account details, private content)
  • The endpoint performs actions (creating, updating, deleting resources)
  • The endpoint relies on ambient authority (cookies, session tokens) for authentication

Even though * prevents credentials: "include", some authentication mechanisms use URL parameters or custom headers that are not classified as "credentials." An API that authenticates via API keys in query strings and returns Access-Control-Allow-Origin: * is wide open to any website stealing data.

Key Rules
  1. 1An origin is scheme + host + port. All three must match for same-origin.
  2. 2SOP blocks reading cross-origin responses, not sending requests. The request still reaches the server.
  3. 3CORS is a server-side opt-in. The server sends headers telling the browser what to allow.
  4. 4Preflight (OPTIONS) fires for non-simple requests. Cache it with Access-Control-Max-Age to avoid latency.
  5. 5Wildcard (*) with credentials is banned. Always specify the exact origin when using Access-Control-Allow-Credentials.
  6. 6Always set Vary: Origin when dynamically setting Access-Control-Allow-Origin based on the request.
  7. 7Dev proxies bypass CORS by moving the request server-side. Use rewrites in Next.js or proxy in Vite.
What developers doWhat they should do
Adding CORS headers on the frontend (meta tags, fetch headers)
The browser controls SOP enforcement. There is no client-side header or meta tag that bypasses it. Any solution must be server-side.
CORS headers must come from the server in the response. The browser enforces them, but the server sets them.
Using Access-Control-Allow-Origin: * with credentials: include
The browser explicitly rejects wildcard origins when credentials are involved to prevent any-site-can-act-as-you attacks.
Specify the exact origin and add Vary: Origin when supporting credentials
Thinking SOP blocks the request from being sent
This misconception leads developers to skip CSRF protection, thinking SOP is enough.
SOP blocks reading the response, not sending the request. The server still receives and processes the request.
Forgetting Access-Control-Max-Age on preflight responses
Without caching, every non-simple request sends two HTTP requests (preflight + actual), doubling latency.
Set Access-Control-Max-Age to cache preflight results (e.g., 86400 for 24 hours)
Echoing the Origin header without validation
Blindly reflecting the Origin header is functionally identical to wildcard * but also works with credentials, making it strictly worse.
Validate the Origin against an allowlist before echoing it back
Quiz
A server blindly echoes the Origin request header as the Access-Control-Allow-Origin response header without checking an allowlist. Is this secure?

CORS and Caching: The Vary Header Gotcha

When your server dynamically sets Access-Control-Allow-Origin based on the request's Origin header, you must also send Vary: Origin. Without it, here is what can go wrong:

  1. User visits from https://app-a.com. CDN caches the response with Access-Control-Allow-Origin: https://app-a.com
  2. User visits from https://app-b.com. CDN serves the cached response with Access-Control-Allow-Origin: https://app-a.com
  3. Browser blocks the response because the origin does not match

The Vary: Origin header tells intermediate caches to store separate cached copies for each unique Origin value.

What CORS Does Not Protect Against

CORS is not a general-purpose security solution. It specifically controls which origins can read cross-origin responses in a browser.

It does not protect against:

  • Server-to-server requests (curl, Postman, backend services). SOP is browser-only
  • CSRF attacks where the attacker does not need to read the response (form submissions, image tags that trigger GET requests with side effects)
  • Requests from browser extensions with appropriate permissions
  • Requests from non-browser environments (mobile apps, desktop apps, scripts)

CORS is one layer in a defense-in-depth strategy. You still need CSRF tokens, proper authentication, input validation, and Content Security Policy.

Interview Question

Q: A candidate says "CORS prevents unauthorized servers from accessing our API." What is wrong with this statement?

CORS does not protect the server at all. It is a browser-enforced policy that protects the user. Without CORS, a malicious website could use the user's browser to make authenticated requests to your API and read the responses (because the browser automatically attaches cookies). CORS prevents the malicious site from reading those responses. But the server still receives every request. CORS is about controlling which browser-based origins can read responses, not about protecting the server from unauthorized access. Server-side authentication and authorization handle that.

1/11