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:


CORS Request Lifecycle Overview Diagram showing how the browser classifies a cross-origin request, optionally fires a preflight OPTIONS call, validates server response headers, and either allows or blocks the response from reaching JavaScript. The browser and server columns are linked by four horizontal lanes: an optional OPTIONS preflight, its 204 approval, the actual request, and the response body. Browser Server JS calls fetch() or XMLHttpRequest Origin tuple check scheme · host · port Classify request simple vs preflighted Header validation allow or block to JS Preflight — conditional OPTIONS · ACRM · ACRH server approves or rejects OPTIONS request 204 + ACAO · approved Actual request Response headers ACAO · ACAC · Vary ACEH · ACAM Response body preflight (conditional) always fires ACAO Allow-Origin · ACAC Allow-Credentials · ACEH Expose-Headers ACAM Allow-Methods · ACRM/H Access-Control-Request-Method/Headers

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 httpshttp
Host Byte-exact after lowercasing + IDNA normalisation api.example.com = api.example.com api.example.comapp.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:

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:

  1. Preflight phase (conditional): fires an OPTIONS request to confirm the server permits the real request’s method and headers.
  2. Response check phase (always): validates Access-Control-Allow-Origin (and Access-Control-Allow-Credentials when applicable) against the original Origin value.

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:

The preflight OPTIONS request carries:

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:

  1. Access-Control-Allow-Origin must be the exact caller’s origin — the wildcard * is forbidden.
  2. Access-Control-Allow-Credentials must be true.

Violating either constraint causes the browser to treat the response as opaque and discard the body, even if the HTTP status is 200 OK.

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:

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.


Topics in This Section