Server-Side CORS Configuration & Header Management

Server-side CORS configuration is the mechanism by which a web server controls which cross-origin requests browsers are permitted to complete, implemented through the Access-Control-* response header family defined in the WHATWG Fetch Standard (Living Standard, sections 3.2 and 4). Every CORS decision the browser makes is driven entirely by what the server returns — incorrect, missing, or duplicated headers cause requests to be blocked at the browser even when the network connection succeeds.

This guide covers the complete server-side responsibility: preflight routing, header directive semantics, dynamic origin validation, credential isolation, wildcard risk, reverse-proxy gotchas, and production debugging.

Key architectural principles:


Header-Flow Reference

The table below maps every Access-Control-* directive to its role, allowed values, and the browser behaviour it controls.

Header Direction Allowed values Controls
Access-Control-Allow-Origin Response * or single serialized origin Which origin may read the response
Access-Control-Allow-Methods Response (preflight only) Comma-separated method list Which methods are permitted after preflight
Access-Control-Allow-Headers Response (preflight only) Comma-separated header names Which request headers are permitted
Access-Control-Allow-Credentials Response true (only legal value) Whether cookies/auth headers may be sent
Access-Control-Expose-Headers Response Comma-separated header names Which response headers the script may read
Access-Control-Max-Age Response (preflight only) Seconds (integer) How long the browser caches the preflight result
Access-Control-Request-Method Request (preflight) Single method Method the browser intends to send
Access-Control-Request-Headers Request (preflight) Comma-separated header names Headers the browser intends to send
Origin Request Serialized origin or null Identifying origin of the request
Vary Response Origin (among others) Cache key differentiation for CDNs

CORS Preflight Mechanics & OPTIONS Routing

CORS preflight state machine Flowchart showing browser request classification: simple requests go directly to the server; non-simple requests trigger a preflight OPTIONS exchange first, then proceed on success or are blocked on failure. Browser makes fetch() / XHR Simple request? (GET/HEAD/POST + safelist) Send OPTIONS preflight Server validates & echoes headers Direct request + CORS headers BLOCKED (browser error) Script reads response yes no ok fail
Figure 1 — Browser CORS decision flow: simple requests proceed directly; non-simple requests require a successful preflight before the actual request is sent.

The browser classifies every cross-origin request as either simple or non-simple per WHATWG Fetch §4.1.1. A request is simple if it uses GET, HEAD, or POST with only safelisted request headers (Accept, Accept-Language, Content-Language, Content-Type restricted to application/x-www-form-urlencoded, multipart/form-data, or text/plain). Any deviation — a PUT/DELETE/PATCH method, a custom header like Authorization, or Content-Type: application/json — triggers a preflight OPTIONS exchange before the actual request is sent.

The server must intercept OPTIONS requests at the outermost middleware layer, before authentication guards, body parsers, or rate-limit middleware. A correct preflight response carries a 200 or 204 status and three directives: Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Max-Age. Chrome and Safari cap Access-Control-Max-Age at 600 seconds; Firefox accepts up to 86400 seconds. Set 600 for cross-browser consistency.

// Node.js / Express — preflight middleware (place before all routes)
app.use((req, res, next) => {
  const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];
  const origin = req.headers.origin;

  if (allowedOrigins.includes(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();
});

Request Classification Matrix

Method Headers Content-Type Credentials Outcome
GET Safelisted only No Simple — no preflight
POST Safelisted only text/plain No Simple — no preflight
POST Safelisted only application/json No Non-simple — preflight required
GET Authorization present Yes Non-simple — preflight required
PUT Any Any Any Non-simple — preflight required
DELETE Any Any Any Non-simple — preflight required
GET Safelisted only Yes (cookies) Non-simple — preflight required
POST X-Custom-Header application/json Any Non-simple — preflight required

The presence of Authorization in Access-Control-Request-Headers is the most common trigger teams overlook. Even a GET request becomes non-simple the moment the client sends a bearer token.


Access-Control Header Directives & Precedence

Full directive semantics are covered in the Access-Control-* Header Directives reference. The critical precedence rules for the server are:

Duplicate headers cause hard rejection. If two middleware layers both set Access-Control-Allow-Origin, the browser rejects the response with a CORS error even if both values are identical. Use proxy_hide_header in Nginx or equivalent stripping in your proxy to prevent upstream duplication.

Order of evaluation matters. Browsers read Access-Control-Allow-Origin first. If that check fails, the browser stops — it does not evaluate Access-Control-Allow-Credentials or Access-Control-Allow-Headers. Fix origin errors first when debugging cascading failures.

Vary: Origin is a cache-correctness requirement, not optional. When you conditionally echo different origin values, a CDN that does not see Vary: Origin will cache the first response and serve that cached header to all subsequent requests from other origins, causing hard CORS failures for legitimate users. Omitting Vary: Origin is one of the most common production bugs.

Access-Control-Expose-Headers governs script access, not browser blocking. By default, scripts can only read the seven safe response headers (Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma, plus Content-Length in some browsers). To expose X-Request-ID, ETag, or rate-limit headers to JavaScript, list them explicitly.

# Nginx — map-based dynamic origin validation
map $http_origin $cors_origin {
  default          "";
  "https://app.example.com"   $http_origin;
  "https://admin.example.com" $http_origin;
}

server {
  location /api/ {
    # Strip any upstream CORS headers before adding ours
    proxy_hide_header Access-Control-Allow-Origin;
    proxy_hide_header Access-Control-Allow-Credentials;

    add_header Access-Control-Allow-Origin  $cors_origin always;
    add_header Access-Control-Allow-Credentials true always;
    add_header Vary Origin always;

    if ($request_method = OPTIONS) {
      add_header Access-Control-Allow-Methods  'GET, POST, PUT, DELETE, PATCH' always;
      add_header Access-Control-Allow-Headers  'Content-Type, Authorization' always;
      add_header Access-Control-Max-Age        600 always;
      return 204;
    }

    proxy_pass http://backend;
  }
}

The always flag in add_header is essential: without it, Nginx only emits the header on 2xx responses and silently drops it on 4xx/5xx, causing CORS failures on error paths.


Dynamic Origin Validation & Allowlisting

Hardcoding origins in static configuration files creates drift: as staging, preview, and production environments multiply, the allowlist falls out of sync. Runtime validation against a centralized registry scales more reliably.

The canonical approach is exact string matching against a hash set loaded at startup (or refreshed on a short TTL):

# FastAPI / Python — middleware with exact-match allowlist
ALLOWED_ORIGINS: set[str] = {
    "https://app.example.com",
    "https://admin.example.com",
    "https://staging.example.com",
}

@app.middleware("http")
async def cors_middleware(request: Request, call_next):
    origin = request.headers.get("origin", "")
    is_allowed = origin in ALLOWED_ORIGINS

    if request.method == "OPTIONS" and is_allowed:
        return Response(
            status_code=204,
            headers={
                "Access-Control-Allow-Origin": origin,
                "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,PATCH",
                "Access-Control-Allow-Headers": "Content-Type,Authorization",
                "Access-Control-Allow-Credentials": "true",
                "Access-Control-Max-Age": "600",
                "Vary": "Origin",
            },
        )

    response = await call_next(request)
    if is_allowed:
        response.headers["Access-Control-Allow-Origin"] = origin
        response.headers["Access-Control-Allow-Credentials"] = "true"
        response.headers["Vary"] = "Origin"
    return response

Subdomain patterns require full URL parsing. A suffix check for .example.com is insufficient — evil.example.com.attacker.com passes a naive suffix test. Always parse the origin as a URL, extract the hostname, and validate scheme, host, and port separately:

// Safe subdomain allowlist check — never use simple string suffix matching
function isAllowedOrigin(origin) {
  try {
    const url = new URL(origin);
    return (
      url.protocol === 'https:' &&
      (url.hostname === 'example.com' || url.hostname.endsWith('.example.com')) &&
      url.port === ''                    // no non-standard port
    );
  } catch {
    return false;                        // malformed origin → deny
  }
}

For architecture and caching strategies for the registry itself, see Dynamic Origin Validation Patterns.


Credential Sharing & Subdomain Isolation

Cross-origin credential transmission requires explicit consent at three layers simultaneously: the browser fetch() call (or XMLHttpRequest.withCredentials), the cookie attributes, and the server CORS headers.

Cookie requirements for cross-origin sharing:

Attribute Required value Reason
SameSite None Lax blocks cross-origin non-safe methods; Strict blocks all cross-site
Secure Present Browser requires HTTPS when SameSite=None
HttpOnly Recommended Prevents script access to the token
Domain .example.com (leading dot) Enables subdomain sharing if needed
Set-Cookie: session_id=abc123; Path=/; Domain=.example.com; Secure; HttpOnly; SameSite=None

The wildcard + credentials invariant. The WHATWG Fetch Standard prohibits Access-Control-Allow-Credentials: true alongside Access-Control-Allow-Origin: *. Browsers enforce this at the response evaluation step and block the request even after the network round-trip completes. There is no workaround: echo the exact origin string.

Subdomain tenant isolation. Multi-tenant architectures that share a root domain must be careful with Domain=.example.com cookie scoping. A session cookie scoped to the root domain is accessible to every subdomain, including attacker-controlled subdomains that may exist due to subdomain takeover vulnerabilities. Scope cookies to the minimum necessary hostname when isolation between tenants matters. Full isolation patterns are covered in Credential Sync Across Subdomains.


Security Boundary Mapping: Wildcard Prohibitions & Vary Requirements

CORS security boundary matrix A 2x2 grid showing the four combinations of wildcard vs exact origin and no-credentials vs credentials, with outcome labels in each cell. No credentials Credentials (cookies / auth) Origin: * Exact origin Request succeeds. Script reads public data. No authenticated data exposed. Browser BLOCKS response. Spec violation — wildcard forbidden with credentials. Request succeeds. Only listed origin may read. Request succeeds. Authenticated data shared only with validated origin.
Figure 2 — Four combinations of origin value and credential flag. The wildcard + credentials cell is always a browser-enforced block.

Access-Control-Allow-Origin: * permits any origin to read the response body for requests without credentials. This is appropriate for public CDN assets, open APIs, and font files. It is inappropriate for any endpoint that relies on cookies, session tokens, or API keys for authentication — even if those credentials are not explicitly sent in that specific request, because CSRF attacks use the browser’s automatic cookie attachment.

Concrete risks of overly permissive policies:

For detailed threat-modelling and boundary enforcement strategies, see Wildcard Risks & Mitigation.


Infrastructure Interaction: CDN, WAF & Reverse-Proxy Gotchas

Most CORS bugs in production are not application bugs — they are proxy-layer bugs. The application returns correct headers, but an intermediate layer strips, duplicates, or overwrites them.

Nginx add_header without always: Nginx only emits add_header directives on responses with status codes 200, 201, 204, 206, 301, 302, 303, 304, and 307. A 401 or 500 response silently drops all CORS headers, so the browser sees no Access-Control-Allow-Origin and reports a CORS failure rather than the actual HTTP error. Always add the always flag.

AWS ALB / API Gateway header injection: AWS Application Load Balancer can return its own CORS headers on OPTIONS responses when CORS support is enabled at the ALB level. If your backend also emits CORS headers, the response will contain duplicates and browsers will reject it. Disable CORS at the ALB level and manage it entirely in the application, or disable it in the application and use the ALB — never both.

Cloudflare cache and Vary: Origin: Cloudflare respects Vary: Origin but only when the response is cacheable. If your CORS endpoint returns Cache-Control: no-store, Cloudflare does not cache it at all, and Vary has no effect. For cacheable CORS responses (GET public APIs, static JSON), ensure Vary: Origin is present and Cache-Control has a non-zero max-age. Use a proxy bypass strategy for preflight caching at the edge.

WAF header inspection: Some WAF rules block requests containing Access-Control-Request-Headers values that list non-standard header names, treating them as injection attempts. Review WAF logs for blocked preflight OPTIONS requests before concluding the issue is server-side.

Load-balancer health check conflicts: Health check probes sometimes arrive on the same port as API traffic. If the health checker does not set an Origin header, a strict middleware that adds CORS headers only on origin-present requests will return different header sets for health vs. real traffic, causing CDN inconsistency.


Debugging Workflow: DevTools + curl

Step-by-step trace

  1. Open DevTools Network tab. Filter by Fetch/XHR or type options in the filter to isolate preflight requests.
  2. Inspect the OPTIONS request. Confirm Origin, Access-Control-Request-Method, and Access-Control-Request-Headers are present and correct.
  3. Inspect the OPTIONS response. Verify status is 200 or 204. Check that Access-Control-Allow-Origin matches the request Origin exactly (case-sensitive). Check Access-Control-Allow-Headers includes every header the request sends.
  4. Inspect the actual request. If the preflight passed, the browser sends the real request. Check the response again for Access-Control-Allow-Origin and Vary: Origin on the actual response — preflight success does not carry over to the main response.
  5. Read the console error. Browser CORS errors specify which directive failed. “Response to preflight request doesn’t pass access control check” followed by “No ‘Access-Control-Allow-Origin’ header” means the preflight returned no matching header, not that the origin is wrong.

curl synthetic validation

# Test preflight response
curl -si -X OPTIONS https://api.example.com/v1/data \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: Authorization, Content-Type" \
  | grep -i "access-control\|vary\|HTTP/"

# Test actual request with credentials
curl -si -X POST https://api.example.com/v1/data \
  -H "Origin: https://app.example.com" \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"key":"value"}' \
  | grep -i "access-control\|vary\|HTTP/"

Error-code-to-cause mapping

Browser error or HTTP status Root cause Remediation
“No ‘Access-Control-Allow-Origin’ header” on OPTIONS 200 Origin not in allowlist; header set only on allowed origins Emit Access-Control-Allow-Origin to blocked origins too (empty value) OR return no header — never an incorrect origin
“No ‘Access-Control-Allow-Origin’ header” on OPTIONS 404 OPTIONS route not registered; caught by 404 handler before CORS middleware Move CORS middleware before routing; add explicit OPTIONS catch-all
“No ‘Access-Control-Allow-Origin’ header” on 401 add_header without always in Nginx; middleware exits before CORS headers are set Add always flag; ensure CORS headers are set before auth middleware exits
“is not allowed by Access-Control-Allow-Headers” Authorization or custom header not listed in Access-Control-Allow-Headers Add missing header name to the preflight response
“credentials flag is ‘true’” but origin is * App returns wildcard despite credentials being enabled Echo exact origin string; never mix * with Allow-Credentials: true
Intermittent failures after CDN deployment Vary: Origin missing; CDN serving cached header from different origin Add Vary: Origin to all dynamic CORS responses
OPTIONS returns 200 but POST still fails Preflight passes but main response lacks Access-Control-Allow-Origin Set CORS headers on both the OPTIONS handler and the actual request handler

Common Implementation Mistakes

Issue Technical impact Remediation
Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true Browser rejects response; WHATWG spec violation. Script cannot read the body. Echo the exact validated origin. Set the credentials header only when origin is in the allowlist.
Omitting Vary: Origin on dynamic responses CDN caches the first-seen origin header and serves it to all subsequent callers. Causes hard CORS failures for other legitimate origins. Append Vary: Origin to every response that conditionally sets Access-Control-Allow-Origin.
Access-Control-Max-Age set to values above 600 expecting Chrome to respect them Chrome and Safari silently cap at 600 seconds. Setting 86400 has no effect in those browsers. Cap at 600 seconds. Accept that Firefox will honour higher values.
Reverse proxy emitting and application also emitting CORS headers Duplicate Access-Control-Allow-Origin values trigger hard rejection. Pick one layer. Use proxy_hide_header (Nginx) or equivalent to suppress upstream duplicates.
add_header in Nginx without always flag CORS headers absent on 4xx/5xx responses. Browser reports a CORS error instead of the HTTP error. Add the always flag to every add_header directive for CORS headers.
Regex matching on the raw Origin string Bypass via crafted origin values; e.g. evilexample.com passes example\.com suffix match. Parse as URL; validate scheme, host, and port separately.
Setting CORS headers before reading Origin When origin is absent (same-origin requests, server-to-server), the header echoes an empty string, causing downstream issues. Always guard the CORS logic behind if (origin).
Applying CORS middleware after authentication middleware Auth rejects the preflight with 401 before CORS headers are written. Browser sees no CORS header and reports a CORS error. CORS middleware must run first; preflights must not require authentication.

Frequently Asked Questions

How does the browser cache preflight responses?

Browsers store the preflight result keyed by the requesting origin, the target URL, and the method/headers combination, for the duration specified by Access-Control-Max-Age. During that window, matching requests skip the OPTIONS round-trip. Chrome and Safari cap the cache at 600 seconds; Firefox at 86400. Vary: Origin is required for cache correctness when multiple origins share the same endpoint — without it, CDNs conflate the per-origin entries.

Why does the browser block credential requests when the response contains a wildcard origin?

The WHATWG Fetch Standard (§3.2.3, “CORS protocol and credentials”) makes this an unconditional rule: if Access-Control-Allow-Credentials is true, the browser checks whether Access-Control-Allow-Origin equals * and immediately fails the check if so. This prevents an authenticated cross-origin response from being readable by any origin. There is no opt-out and no browser flag to override it.

What is the difference between simple and preflighted requests per the WHATWG Fetch spec?

A request is simple (CORS-safelisted) if it meets all of: method is GET, HEAD, or POST; every header is on the CORS-safelisted header list; Content-Type (if present) is one of application/x-www-form-urlencoded, multipart/form-data, or text/plain; there is no ReadableStream body; and no EventSource. Violating any condition triggers a preflight. The Authorization header and application/json content type are the two most frequent triggers in API traffic.

Why must Vary: Origin be present on dynamic CORS responses?

When a server conditionally echoes different Access-Control-Allow-Origin values depending on the requesting origin, the response is semantically different for each origin even though the URL is the same. HTTP caches key on the URL by default. Without Vary: Origin, a cache stores the first-seen response and serves it to all subsequent requesters, including those from different origins. For CDN-cached public APIs, this means one user’s allowed origin header is served to a blocked origin — which either fails (if the cached value does not match their origin) or succeeds incorrectly (if the cached value is from an allowed origin but the current requester should be blocked).

How do reverse proxies interfere with CORS headers?

Three failure modes are common. First, the proxy strips all upstream Access-Control-* headers by default (some WAF configurations). Second, the proxy adds its own CORS headers, creating duplicates alongside the application’s headers. Third, the proxy only forwards the body and omits headers on error responses. In Nginx, use proxy_hide_header to strip upstream, add_header ... always to set your own, and verify with curl -v against the proxy directly.

Can subdomain wildcards be used safely in the allowlist?

Yes, but only with full URL parsing. Substring or suffix matching on the raw Origin string is exploitable: the origin https://app.example.com.evil.com passes a naive endsWith('.example.com') check. Parse the origin as a URL with new URL(origin), then validate url.protocol, url.hostname, and url.port independently.

How should CORS interact with Content-Security-Policy?

CORS and CSP are independent mechanisms. CORS controls which origins can read responses; CSP controls which resources the page itself may load. They do not override or interact with each other. A misconfigured CSP connect-src directive that blocks an API domain will cause a network-level failure before CORS is evaluated, which can make it look like a CORS problem in DevTools.


Topics in This Section