How Browsers Evaluate the Same-Origin Policy

The browser console error you are debugging is almost always produced in one place: the SOP evaluation step inside the browser’s networking layer, where the engine compares the serialized (scheme, host, port) tuple of the requesting page against the Access-Control-Allow-Origin header the server returned.

This page is part of Origin Matching Rules & Validation, which covers the complete tuple comparison algorithm and allowlist patterns. If you landed here with a No 'Access-Control-Allow-Origin' header is present error, the steps below walk you from console error to verified fix.


Exact browser console error this page resolves

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

Variant with credential mismatch:

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

Root cause

The WHATWG Fetch specification (§3.2 and §4.10) places SOP evaluation inside the browser’s fetch algorithm, not in JavaScript. When your code calls fetch() or XMLHttpRequest.open(), the browser serializes the page’s origin as a (scheme, host, port) tuple, attaches it as the Origin request header, and then — after the server responds — compares that tuple byte-for-byte against Access-Control-Allow-Origin. A single character difference (wrong port, wrong scheme, trailing slash on the server’s allowlist) causes rejection. The response body is received by the browser but is permanently suppressed from JavaScript; no error object exposes the payload.

The diagram below shows the two decision points where evaluation can fail: during the preflight OPTIONS request and again on the actual response.

Browser same-origin policy evaluation flow Two-stage flow: preflight OPTIONS evaluation and actual response evaluation, each with a pass/fail branch. JS calls fetch() or XHR.open() Non-simple request? (custom headers, PUT/DELETE/PATCH, credentials) Preflight OPTIONS CORS headers match? (step 1) Actual response CORS headers match? (step 2) Preflight blocked Console error Response blocked Console error JS receives response data Yes No Fail Pass Fail Pass

Prerequisite state

Before applying the fixes below, confirm:


Step-by-step fix

Step 1 — Identify the exact Origin value the browser sends

Open DevTools → Network → filter by Fetch/XHR. Click the failing request. In the Request Headers pane, copy the Origin value character-for-character, including the port number if present.

Origin: https://app.example.com:8080

Any mismatch between this string and your server’s allowlist — even a missing :8080 — causes rejection. Splitting the header into its three components makes it obvious which allowlist entries can never match it:

Component-by-component origin comparison The Origin header https://app.example.com:8080 is dissected into scheme, host and port. Three candidate allowlist entries are then compared component by component: only the identical entry is allowed, while a missing port and a downgraded scheme are both blocked. The exact Origin header the browser put on the wire https scheme app.example.com host 8080 port allowlist entry scheme host port verdict https://app.example.com:8080 match match match allowed https://app.example.com match match absent blocked http://app.example.com:8080 differs match match blocked All three components are compared byte-for-byte; one differing character denies the read

Step 2 — Fix the preflight OPTIONS response

If DevTools shows an OPTIONS request immediately before the failing request, add CORS headers to your server’s preflight handler. The server must echo back the exact origin it received, not a hardcoded value:

Express / Node.js

const allowedOrigins = new Set([
  'https://app.example.com:8080',
  'https://admin.example.com'
]);

app.options('*', (req, res) => {
  const origin = req.headers['origin'];
  if (allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Custom-Auth');
    res.setHeader('Access-Control-Max-Age', '86400');
    res.setHeader('Vary', 'Origin');
  }
  res.sendStatus(204);
});

Reflect the exact origin per dynamic origin validation patterns — never hardcode a static value in a multi-origin setup.

Nginx

map $http_origin $cors_origin {
    default "";
    "https://app.example.com:8080" "https://app.example.com:8080";
    "https://admin.example.com"    "https://admin.example.com";
}

server {
    location /api/ {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin  $cors_origin always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
            add_header Access-Control-Max-Age       86400 always;
            add_header Vary                         Origin always;
            return 204;
        }
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Vary                        Origin always;
        proxy_pass http://upstream;
    }
}

Step 3 — Add CORS headers to the actual response too

The browser runs the same evaluation on the actual GET/POST/etc. response even after a passing preflight. The Access-Control-Allow-Origin header must appear on both the OPTIONS response and the real one:

app.use((req, res, next) => {
  const origin = req.headers['origin'];
  if (allowedOrigins.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  next();
});

This second evaluation is where a 200 OK still ends as a TypeError, because the check happens between the browser’s network layer and your JavaScript rather than on the wire:

Where the response body is discarded An annotated response block shows a 200 OK with a JSON content type and no access-control-allow-origin header. The browser network layer holds the full body, runs the CORS check, and on a mismatch discards the body so fetch rejects with a TypeError instead of handing data to script. Response as it arrives at the browser HTTP/1.1 200 OK content-type: application/json (no access-control-allow-origin) the body arrives intact over the wire but the allow header never came with it Network layer holds the full body Same-origin check does the allow value match? match Body handed to script response.json() resolves no match Body discarded fetch() rejects with TypeError the console prints the CORS message; no error object ever exposes the payload

Step 4 — Handle credentials separately if needed

If your frontend passes credentials: 'include', the browser rejects wildcard responses regardless of whether the origin matches. Add Access-Control-Allow-Credentials: true only on endpoints that truly need cookies or auth headers, and never combine it with a wildcard Access-Control-Allow-Origin. See understanding Access-Control-Allow-Credentials for the full rules.

res.setHeader('Access-Control-Allow-Credentials', 'true');
// Access-Control-Allow-Origin must be the exact origin string here, never '*'
res.setHeader('Access-Control-Allow-Origin', origin);

Verification

curl probe

curl -si -X OPTIONS \
  -H 'Origin: https://app.example.com:8080' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: Content-Type' \
  https://api.example.com/v1/data | grep -i 'access-control\|vary\|HTTP'

Expected output:

HTTP/2 204
access-control-allow-origin: https://app.example.com:8080
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers: Content-Type, Authorization
access-control-max-age: 86400
vary: Origin

DevTools checklist


Security boundary note

Do not reflect Origin unconditionally without checking it against an allowlist. Code such as res.setHeader('Access-Control-Allow-Origin', req.headers.origin) with no validation is functionally equivalent to * and permits any site to read credentialed responses from your API. Always validate against an explicit set. Omitting Vary: Origin is equally dangerous: CDN edges cache the first seen Access-Control-Allow-Origin value and serve it to all subsequent origins, silently breaking or silently permitting cross-origin access depending on cache order.


Common mistakes

Mistake What actually happens Fix
Allowlist stores https://app.example.com but browser sends https://app.example.com:8080 Exact string comparison fails; browser blocks the response Include the full host:port string in the allowlist
Access-Control-Allow-Origin: * with credentials: 'include' Browser rejects the response per spec §3.2.5; JavaScript sees a network error Use dynamic origin reflection; add Access-Control-Allow-Credentials: true
CORS headers on OPTIONS only, omitted on actual response Preflight passes; actual response is blocked at the second evaluation point Mirror CORS headers on every response, not just OPTIONS
Missing Vary: Origin CDN serves wrong origin header to subsequent callers; intermittent failures in production Add Vary: Origin unconditionally on any response that carries Access-Control-Allow-Origin

FAQ

Why does the browser block a request even when the server returns 200 OK?

The browser receives the full response but inspects the CORS headers before exposing any data to JavaScript. A 200 OK without a matching Access-Control-Allow-Origin header causes the browser to suppress the response body — the network call succeeds at the TCP level, but the SOP evaluation step discards the result before your code sees it.

Does same-origin policy apply to <script> and <img> tags?

No. Resources embedded via <img>, <script>, or <link> bypass SOP network restrictions and load cross-origin freely. SOP applies to programmatic reads via XMLHttpRequest and the Fetch API, and to DOM access across frames with different origins.

How do I tell if my CORS failure is a preflight failure or an actual response failure?

Open DevTools Network, filter by All, and look for an OPTIONS request immediately before the failing request. If the OPTIONS returns a non-2xx status or lacks the required CORS headers, that is a preflight failure. If OPTIONS succeeds but the subsequent request still fails, the server is omitting CORS headers from the actual response, not just the preflight.