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.
Prerequisite state
Before applying the fixes below, confirm:
- Your server already handles the HTTP method your frontend sends (
GET,POST,PUT, etc.). - You know whether your request is “simple” (no custom headers, no credentials, method is
GET/POST/HEADwith a safelistedContent-Type) or non-simple. Non-simple requests trigger a preflight you must handle separately. - You have access to modify the server’s response headers — framework middleware, Nginx config, or CDN origin rules.
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.
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();
});
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.
Related
- Origin Matching Rules & Validation — parent page covering the full tuple comparison algorithm
- Debugging Missing Access-Control-Allow-Origin Header
- Dynamic Origin Validation Patterns
- Options Endpoint Design
- Header Deduplication Techniques (Vary: Origin)