Core CORS Mechanics & Same-Origin Policy Fundamentals
The Same-Origin Policy (SOP) is a mandatory browser security boundary defined in the WHATWG Fetch specification that prevents a document or script loaded from one origin from reading resources at a different origin. Cross-Origin Resource Sharing (CORS) is the W3C-standardised opt-in mechanism — delivered through HTTP response headers — that lets servers selectively relax that boundary for specific callers.
Key architectural invariants:
- An origin is the immutable three-part tuple
(scheme, host, port); all three components must match exactly for two URLs to share an origin. - SOP restricts JavaScript from reading cross-origin response bodies; it does not prevent the browser from sending the request in the first place.
- CORS headers are set on the response by the server; the browser enforces them on the client — a server cannot opt itself out of enforcement.
- A preflight
OPTIONSrequest fires before any cross-origin request that uses a non-safe method or a non-safelisted header; the server must explicitly approve it. Access-Control-Allow-Origin: *andAccess-Control-Allow-Credentials: trueare mutually exclusive — the Fetch spec forbids combining them.- Omitting
Vary: Originon dynamic CORS responses causes CDNs to cache-poison subsequent callers. - SOP scopes
localStorage,sessionStorage, IndexedDB, and cookies to their originating tuple; CORS does not relax storage isolation.
Same-Origin Policy Architecture & the Origin Tuple
The Same-Origin Policy defines the security perimeter that browsers enforce between browsing contexts. Per the WHATWG URL Standard, an origin is the three-part tuple (scheme, host, port). Two URLs share an origin only when all three components are byte-identical after normalisation.
For detailed parsing logic, default-port elision, and browser normalisation edge cases — including how null origins arise from file: URLs and sandboxed iframes — see Origin Matching Rules & Validation.
Origin tuple reference table
| Component | Matching rule | Positive example | Negative example |
|---|---|---|---|
| Scheme | Byte-exact after lowercasing | https = https |
https ≠ http |
| Host | Byte-exact after lowercasing + IDNA normalisation | api.example.com = api.example.com |
api.example.com ≠ app.example.com |
| Port | Numeric match; default port is implicit (80 for http, 443 for https) | :443 = (omitted on https) |
:3000 ≠ :8080 |
https://app.example.com:443 and https://app.example.com share an origin because 443 is the implicit default for HTTPS. http://app.example.com does not share that origin because the scheme differs, which changes the port default as well.
What SOP restricts and what it does not
SOP is frequently misunderstood as blocking all cross-origin network traffic. It does not. The restrictions are specific:
- Blocked: JavaScript reading the response body of a cross-origin fetch without CORS headers.
- Blocked: JavaScript accessing the DOM of a cross-origin frame or window.
- Blocked: JavaScript reading cookies,
localStorage,sessionStorage, or IndexedDB belonging to another origin. - Permitted: Sending cross-origin requests via
<form>,<img>,<script>,<link>,<video>, and<iframe>tags — these bypass the network restriction but the response content remains opaque to JavaScript. - Permitted: Sending cross-origin
fetch()orXMLHttpRequest— the browser sends it, but blocks the script from reading the response unless the server sets CORS headers.
The second point explains why credential sharing across subdomains requires careful planning: app.example.com and api.example.com are different origins, so cookies and storage are siloed even on the same registrable domain.
How the Browser Enforces the CORS Boundary
CORS enforcement is a client-side behaviour. The browser appends an Origin request header to every cross-origin fetch, then validates the response before exposing it to JavaScript. The server has no power to tell the browser to skip this check — the mechanism is entirely browser-controlled.
The enforcement algorithm (WHATWG Fetch §4.10) runs in two phases:
- Preflight phase (conditional): fires an
OPTIONSrequest to confirm the server permits the real request’s method and headers. - Response check phase (always): validates
Access-Control-Allow-Origin(andAccess-Control-Allow-Credentialswhen applicable) against the originalOriginvalue.
If either phase fails, the browser suppresses the response body, logs a CORS error to the console, and rejects the fetch() promise with a TypeError: Failed to fetch. The network request itself completed — the block happens at the read boundary between browser and JavaScript.
Preflight state machine
The preflight fires when the request fails any of the following “simple request” criteria:
- Method is not one of
GET,HEAD,POST. - A request header is outside the CORS-safelisted set:
Accept,Accept-Language,Content-Language,Content-Type(limited totext/plain,application/x-www-form-urlencoded,multipart/form-data). Content-Typeisapplication/json,application/xml, or any other value not in the safelisted subset.- A
ReadableStreamis used in the request body.
The preflight OPTIONS request carries:
Access-Control-Request-Method: the method the real request will use.Access-Control-Request-Headers: a comma-separated list of non-safelisted headers.
The server must respond with a 2xx status (conventionally 204 No Content) containing Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Allow-Origin. A missing or mismatched header causes immediate request cancellation before the real payload ever leaves the client.
Request Classification Matrix
This table maps common request configurations to their CORS path. For full threshold analysis and the browser-level algorithm, see Simple vs Preflight Requests.
| Method | Content-Type | Custom headers | Credentials | Path |
|---|---|---|---|---|
GET |
— | None | No | Simple — no preflight |
GET |
— | Authorization |
No | Preflight — non-safelisted header |
POST |
application/x-www-form-urlencoded |
None | No | Simple — no preflight |
POST |
application/json |
None | No | Preflight — non-safelisted Content-Type |
POST |
application/json |
Authorization |
include |
Preflight — method + header + credentials |
PUT / DELETE / PATCH |
Any | Any | Any | Preflight — non-simple method |
GET |
— | None | include |
Simple body, but credentials require exact-origin response |
OPTIONS (manual) |
— | Any | No | Sent directly; does not trigger a second preflight |
Server-Side Enforcement: the Canonical Header Set
Every CORS response must include Access-Control-Allow-Origin. The remaining headers are conditional. The table below maps header to purpose, allowed values, and the conditions under which omitting it causes a failure.
| Header | Required for | Allowed values | Omit consequence |
|---|---|---|---|
Access-Control-Allow-Origin |
All cross-origin responses | Exact origin string, or * (no credentials) |
Browser blocks response — always |
Access-Control-Allow-Credentials |
Any credentials: 'include' request |
true only (false is not meaningful) |
Cookies / auth headers stripped from the visible response |
Access-Control-Allow-Methods |
Preflight responses | Comma-separated method list | Preflight fails; real request cancelled |
Access-Control-Allow-Headers |
Preflight responses with non-safelisted headers | Comma-separated header names (case-insensitive) | Preflight fails if the header is in ACRH |
Access-Control-Max-Age |
Preflight responses | Seconds (Chrome cap: 7200; Firefox cap: 86400) | Preflight fires on every matching request |
Access-Control-Expose-Headers |
Any response where JS reads custom headers | Comma-separated header names | JS reads undefined for unlisted response headers |
Vary |
Any dynamic ACAO response |
Must include Origin |
CDN/proxy caches serve wrong origin to subsequent callers |
Nginx — dynamic origin validation
# /etc/nginx/sites-available/api.example.com
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
listen 443 ssl;
server_name api.example.com;
# Attach to every response so the map variable resolves correctly
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
location / {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Request-ID" always;
add_header Access-Control-Max-Age 600 always;
return 204;
}
proxy_pass http://backend_upstream;
}
}
Express.js — strict preflight and CORS middleware
const express = require('express');
const app = express();
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
function corsMiddleware(req, res, next) {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Request-ID');
res.setHeader('Access-Control-Max-Age', '600');
return res.sendStatus(204);
}
next();
}
app.use(corsMiddleware);
Fetch API — credentialed request with custom headers
// This combination triggers a preflight because:
// 1. Content-Type is application/json (non-safelisted)
// 2. Authorization is a non-safelisted header
// 3. credentials: 'include' requires exact-origin matching on the response
const response = await fetch('https://api.example.com/data', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer eyJhbGci...',
},
body: JSON.stringify({ action: 'sync' }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
Security Boundary Mapping: Credential Isolation
Credential handling is the most security-critical dimension of CORS. When credentials: 'include' is set, the browser attaches cookies, HTTP authentication, and client certificates to the cross-origin request. The Fetch specification enforces two hard constraints on the server’s response:
Access-Control-Allow-Originmust be the exact caller’s origin — the wildcard*is forbidden.Access-Control-Allow-Credentialsmust betrue.
Violating either constraint causes the browser to treat the response as opaque and discard the body, even if the HTTP status is 200 OK.
Cookie scoping interacts with CORS
Modern cookie attributes directly affect which cookies the browser attaches to cross-origin requests:
| SameSite value | Cross-origin subresource | Cross-origin top-level navigation |
|---|---|---|
Strict |
Not sent | Not sent |
Lax (default) |
Not sent | Sent on GET only |
None; Secure |
Sent (requires HTTPS) | Sent |
| Absent (legacy) | Sent (browser-dependent) | Sent |
For SameSite=None; Secure cookies to reach a cross-origin API, the server still needs a valid Access-Control-Allow-Credentials: true response with an exact-origin Access-Control-Allow-Origin. Tokens sent via Authorization headers bypass SameSite scoping but still trigger a preflight.
For full session-isolation strategies and understanding Access-Control-Allow-Credentials semantics, see Credential Sharing & Security Boundaries.
Wildcard prohibition and wildcard risks
The prohibition on combining * with credentials is not a browser preference — it is a Fetch specification requirement. Any server that returns Access-Control-Allow-Origin: * is also stating “I accept requests from any origin”, which is only safe for genuinely public, stateless APIs. See Wildcard Risks & Mitigation for the attack surface analysis and safe migration paths.
Infrastructure Interaction: CDN, WAF, and Reverse-Proxy Gotchas
CORS headers transit multiple infrastructure layers between your application server and the browser. Each layer is a potential point of failure.
CDN cache poisoning via missing Vary: Origin
If your CDN caches Access-Control-Allow-Origin: https://app.example.com and a subsequent request arrives from https://admin.example.com, the CDN serves the cached header — which does not match admin.example.com — and the browser blocks the response. Always pair dynamic origin reflection with Vary: Origin to force per-origin cache keys. The header deduplication techniques cluster covers the caching interaction in detail.
WAF blocking OPTIONS preflights
Web Application Firewalls that apply strict HTTP method allowlists often drop OPTIONS by default. The symptom is a net::ERR_EMPTY_RESPONSE or a TypeError: Failed to fetch with no CORS error in the console — the preflight never completed. The fix is to add OPTIONS to the WAF method allowlist and confirm the rule applies to the CORS origin paths. See proxy bypass strategies for common WAF and proxy configurations.
Reverse proxy header stripping
Reverse proxies performing TLS termination may strip or overwrite Access-Control-* headers under certain configurations. Nginx’s proxy_hide_header and add_header directives interact in non-obvious ways: a add_header in a location block silently overrides add_header directives in the parent server block. Always set CORS headers in the most specific block where the preflight response is generated, and use always to ensure headers appear on non-2xx responses.
Access-Control-Max-Age browser caps
Setting a long preflight TTL reduces OPTIONS round-trips. Browser caps are a hard ceiling:
| Browser | Maximum Access-Control-Max-Age |
|---|---|
| Chrome / Edge | 7200 seconds (2 hours) |
| Firefox | 86400 seconds (24 hours) |
| Safari | 600 seconds (10 minutes) |
Safari’s 600-second cap means a value of 86400 is silently truncated. For cache duration tuning across all browser targets, 600 seconds is the safe universal maximum.
Debugging Workflow: DevTools + curl Step-by-Step
Step 1 — Reproduce in the Network panel
Open Chrome DevTools (F12 → Network). Filter by Fetch/XHR. Trigger the failing request. Look for two entries: the preflight OPTIONS (if applicable) and the actual request. A red preflight entry with status (blocked) or a missing entry indicates WAF/proxy interference.
Step 2 — Inspect the preflight response headers
Click the OPTIONS entry. Under the Response Headers tab, confirm:
Access-Control-Allow-Originis present and matches the page’s origin exactly.Access-Control-Allow-Methodsincludes the method the real request needs.Access-Control-Allow-Headersincludes every custom header the real request sends.- HTTP status is
204or another2xx.
Step 3 — Inspect the actual request response headers
Click the actual request entry. Confirm Access-Control-Allow-Origin is again present (some servers only set it on the preflight response, not the actual response — this causes the real request to fail).
Step 4 — Bypass the browser with curl
# Simulate a preflight from the terminal (bypasses browser cache and extensions)
curl -v -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type, Authorization"
Look for < Access-Control-Allow-Origin in the response. If the header is absent here, the problem is server-side. If it is present in curl but absent in the browser, the issue is a CDN cache or a browser extension.
Step 5 — Map the console error to its cause
| Console error | Most likely cause |
|---|---|
No 'Access-Control-Allow-Origin' header is present |
Server not returning ACAO on this route |
The 'Access-Control-Allow-Origin' header contains multiple values |
Duplicate add_header directives in Nginx or a proxy appending its own |
Request header field X-Custom is not allowed |
Header missing from Access-Control-Allow-Headers in preflight response |
Response to preflight has invalid HTTP status code 405 |
WAF or server is rejecting OPTIONS as a disallowed method |
Credentials flag is true, but the 'Access-Control-Allow-Origin' is '*' |
Wildcard used with credentials: 'include' |
Access-Control-Allow-Credentials is false |
Header present but set to false, or absent entirely |
For the full error taxonomy and debugging a missing Access-Control-Allow-Origin header, see CORS Error Code Breakdown.
Common Implementation Mistakes
| Issue | Technical impact | Remediation |
|---|---|---|
Access-Control-Allow-Origin: * with credentials: 'include' |
Fetch spec forbids the combination; browser discards the response body | Reflect exact origin from request header; add Vary: Origin |
Omitting Vary: Origin on dynamic responses |
CDN caches the first origin’s value and serves it to all subsequent callers | Append Vary: Origin unconditionally when ACAO is dynamic |
Setting CORS headers only on the preflight OPTIONS response |
The browser re-validates ACAO on the actual response; missing header blocks the payload |
Set ACAO (and ACAC) on both the OPTIONS and the real response |
WAF / CDN silently dropping OPTIONS |
Preflight never receives a valid response; request silently fails | Allowlist the OPTIONS method in WAF rules; verify with curl |
Nginx add_header in nested blocks overriding parent |
Parent add_header Vary Origin is ignored in child location blocks |
Repeat add_header in every block that generates a CORS response, using always |
Sending Access-Control-Allow-Headers: * with credentials |
The wildcard for ACAH is not supported by all browsers when credentials are active |
Enumerate headers explicitly |
Using Access-Control-Max-Age above 7200 seconds |
Safari silently caps at 600 s; Chrome caps at 7200 s; high values give false confidence | Set to 600 s for maximum cross-browser preflight caching |
FAQ
Why does the browser block cross-origin requests by default?
The Same-Origin Policy prevents malicious scripts from reading sensitive data across different origins. Without it, any page you visit could silently fetch your bank balance, private API responses, or session-bound resources. CORS is the explicit opt-in that lets servers selectively grant read access to specific callers.
How does the preflight OPTIONS request differ from a standard GET or POST?
A preflight is a browser-initiated validation step. It fires before the real request and uses the OPTIONS method to ask whether the server permits the real request’s method and headers. The server’s Access-Control-Allow-* response either grants or denies permission. If the preflight is approved, the browser sends the real request; if denied, it cancels it before a byte of payload leaves the client.
Can Access-Control-Allow-Origin: * be safely used with authentication cookies?
No. The Fetch specification explicitly forbids combining * with Access-Control-Allow-Credentials: true. Any browser receiving that combination will silently discard the response body, even if the status is 200 OK. Reflect the exact origin and enumerate your allowed origin list server-side.
What does Vary: Origin do and why is it required?
Vary: Origin is a cache-control directive that tells CDNs and reverse proxies to store a separate cached copy of the response for each distinct Origin request header value. Without it, a CDN stores the first response — with Access-Control-Allow-Origin: https://app.example.com — and serves that cached value to https://admin.example.com, causing CORS failures for the second caller.
Does SOP apply to embedded <img> and <script> tags?
SOP does not block the network request for embedded resources; it blocks JavaScript from reading their content. An <img> loads cross-origin images freely, but <canvas>.getImageData() on a tainted canvas throws a security error. A <script> executes cross-origin scripts but JavaScript cannot inspect their source. CORS provides the mechanism for scripts to gain read access to cross-origin response bodies.
What happens to a cross-origin 3xx redirect?
If a cross-origin request is redirected, the browser strips the Origin header from the redirected request (per the WHATWG Fetch spec), and the final response must still carry a valid Access-Control-Allow-Origin. Credentialed requests that redirect produce an opaque response — credential leakage across redirect hops is blocked by design.
Related
Topics in This Section
Credential Sharing & Security Boundaries in CORS
How browsers enforce credential isolation for cross-origin requests: Access-Control-Allow-Credentials mechanics, cookie SameSite interaction, subdomain trust, and production debugging workflows.
Origin Matching Rules & Validation
How browsers and servers validate cross-origin access through exact origin-tuple matching. Covers the RFC 6454 parsing algorithm, allowlist patterns, edge cases, and debugging workflows.
Simple vs Preflight Requests: CORS Mechanics
Understand exactly when browsers send a preflight OPTIONS request vs a direct simple request, what headers control each path, and how to configure Nginx and Node/Express to handle both correctly.
CORS Error Code Breakdown
Browser-enforced CORS error codes mapped to HTTP status responses, console warnings, and network traces — with fix patterns for each error type.