Access-Control-* Header Directives

Without correct Access-Control-* response headers, every cross-origin fetch() or XMLHttpRequest either fails silently or triggers a browser console error — regardless of how well your application logic is written. A missing Access-Control-Allow-Origin, a mismatched credential flag, or an absent Vary: Origin can block entire API surfaces, leak cached responses to unintended origins, or prevent JavaScript from reading headers it legitimately needs.

This page is part of Server-Side CORS Configuration & Header Management, which covers the full server-side enforcement model. Here the focus is on the exact header semantics, browser-specific parsing rules, and the implementation patterns that hold up under production load.


Spec Anchor

The Access-Control-* response headers are defined in the WHATWG Fetch Standard, specifically the CORS protocol steps in §3.2. The algorithm describes how a browser validates each header field after a preflight OPTIONS exchange or a simple cross-origin response. Browser engines implement this spec independently, which is why Chrome, Firefox, and Safari differ on edge cases like Max-Age ceiling values and whitespace handling in header lists.


Preflight Flow: What the Browser Evaluates

Before understanding individual headers, it helps to see how they fit into the two-leg exchange the browser performs for non-simple requests.

Access-Control-* Preflight Flow Sequence diagram showing the browser sending a preflight OPTIONS request with Access-Control-Request-Method and Access-Control-Request-Headers, the server responding with Access-Control-Allow-* headers, and the browser then sending the actual request only if validation passes. Browser Network Server fetch() called OPTIONS /resource Access-Control-Request-Method: POST Access-Control-Request-Headers: Authorization Validate origin 204 No Content Access-Control-Allow-Origin / Methods / Headers / Max-Age Validate preflight resp. Pass? No → Block + error Yes → actual POST request 200 OK + Access-Control-Expose-Headers JS reads response

Header Reference Table

Every Access-Control-* header operates in a specific phase — preflight response, actual response, or both. The table below maps each header to its phase, allowed values, browser-enforced limits, and whether it is required or optional.

Header Phase Allowed Values Default Browser Limit / Note
Access-Control-Allow-Origin Both Exact origin string or * None (required) * forbidden with credentials
Access-Control-Allow-Methods Preflight Comma-separated HTTP methods or * None * is literal string when credentials present
Access-Control-Allow-Headers Preflight Comma-separated header names or * None * is literal string when credentials present
Access-Control-Max-Age Preflight Non-negative integer (seconds) Varies by browser Chrome/Safari cap: 600 s; Firefox cap: 86400 s
Access-Control-Allow-Credentials Both true (exact string, lowercase) Absent = false Any other value treated as absent
Access-Control-Expose-Headers Actual response Comma-separated header names or * None * is literal when credentials present
Access-Control-Request-Method Preflight request Single HTTP method Sent by browser, not server
Access-Control-Request-Headers Preflight request Comma-separated header names Sent by browser, not server

Step-by-Step Implementation

1. Identify the preflight trigger

A preflight OPTIONS request fires when any of these conditions are true:

The browser sends Access-Control-Request-Method and Access-Control-Request-Headers in the preflight so the server knows exactly what capability it is authorising.

2. Respond to the preflight (Nginx)

# nginx.conf — CORS preflight and response headers
map $http_origin $cors_origin {
    default                        "";
    "https://app.example.com"      "https://app.example.com";
    "https://dashboard.example.com" "https://dashboard.example.com";
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location /v1/ {
        # Preflight fast-path
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin'  $cors_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-Request-Id' always;
            add_header 'Access-Control-Max-Age'       '600' always;
            add_header 'Access-Control-Allow-Credentials' 'true' always;
            add_header 'Vary'                         'Origin' always;
            return 204;
        }

        # Actual request headers
        add_header 'Access-Control-Allow-Origin'       $cors_origin always;
        add_header 'Access-Control-Allow-Credentials'  'true' always;
        add_header 'Access-Control-Expose-Headers'     'X-Request-Id, X-RateLimit-Remaining' always;
        add_header 'Vary'                              'Origin' always;

        proxy_pass http://backend;
    }
}

The always flag ensures headers are appended even on 4xx/5xx responses, where browsers still enforce CORS before exposing error details to JavaScript.

3. Respond to the preflight (Express/Node.js)

// cors-middleware.js
const ALLOWED_ORIGINS = new Set([
  'https://app.example.com',
  'https://dashboard.example.com',
]);

function corsMiddleware(req, res, next) {
  const origin = req.headers['origin'];

  if (origin && 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, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Request-Id');
    res.setHeader('Access-Control-Max-Age', '600');
    return res.sendStatus(204);
  }

  // Expose non-safelisted headers on actual responses
  res.setHeader('Access-Control-Expose-Headers', 'X-Request-Id, X-RateLimit-Remaining');
  next();
}

module.exports = corsMiddleware;

Safe origin reflection — where the server only echoes the Origin value when it is on the allowlist — is the correct alternative to a wildcard for credentialed requests. See Dynamic Origin Validation Patterns for production-grade allowlist management, including regex guards and subdomain normalization.

4. Return the actual response with expose headers

On the actual (non-preflight) response, Access-Control-Allow-Methods and Access-Control-Allow-Headers have no effect. The browser only reads Access-Control-Allow-Origin, Access-Control-Allow-Credentials, and Access-Control-Expose-Headers from the actual response. Sending the full set on the actual response is harmless but add-headers-on-actual-response should always include Vary: Origin.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
Vary: Origin
Content-Type: application/json

Without Access-Control-Expose-Headers, only the seven CORS-safelisted response headers (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma) are readable by response.headers.get() in JavaScript. Custom telemetry headers like X-Request-Id are silently hidden.


Edge Cases and Security Boundaries

Null origin and sandboxed iframes

Requests from sandboxed <iframe> elements without allow-same-origin, data URIs, and some local file schemes send Origin: null. Never allowlist null as a trusted origin — it is a synthetic value shared by many unrelated contexts. A server that reflects Access-Control-Allow-Origin: null with credentials enabled exposes its API to any sandboxed page an attacker constructs.

Subdomain mismatch

https://api.example.com and https://app.example.com are distinct origins. A server that allowlists only https://example.com will reject requests from its own subdomains. Conversely, a regex that matches *.example.com without anchoring will accept https://evil-example.com — ensure your pattern is anchored and requires a dot before the root domain.

Opaque responses and no-cors mode

fetch() in no-cors mode returns an opaque response — the browser blocks access to headers, body, and status regardless of server-side CORS headers. This mode is for fire-and-forget side-effect requests (like beacon pings) where the response is intentionally unreadable.

Wildcard with credentials

The Fetch Standard §3.2.3 states: if credentials is include and Access-Control-Allow-Origin is *, the browser must return a network error. The same applies to Access-Control-Allow-Headers: * and Access-Control-Expose-Headers: * — in credentialed contexts, * is treated as a literal header name, not a wildcard, causing silent header allowlist failures. See Wildcard Risks & Mitigation for the full security boundary analysis.

Browser parsing: trailing commas and whitespace

Blink and WebKit engines trim whitespace around comma-separated values but treat trailing commas as an empty final entry. A value of Content-Type, Authorization, (trailing comma) may cause silent rejection of Authorization on some browser versions. Validate your header values with the curl verification checklist below before deploying.


Proxy and CDN Interaction

Missing Vary: Origin poisons the cache

CDN edge nodes cache the first Access-Control-Allow-Origin response they receive and serve it to every subsequent request for that URL — regardless of the actual Origin header on the inbound request. If the first cached response contains Access-Control-Allow-Origin: https://app.example.com, all users from https://dashboard.example.com receive a mismatched origin and are blocked. Adding Vary: Origin instructs the CDN to maintain a separate cache entry per distinct origin value.

For deeper coverage of how Vary interacts with CDN caching layers, see Handling Vary: Origin Header Correctly.

Access-Control-Max-Age and browser cache

The preflight cache is browser-side, not CDN-side. Access-Control-Max-Age: 600 tells the browser to skip the preflight OPTIONS round-trip for up to 600 seconds for the same URL, method, and header combination. The CDN never sees the cached preflights — they happen locally in the browser’s preflight cache. This means high Max-Age values mask server-side header changes during debugging, even after a CDN purge.

Browser Effective Maximum Max-Age
Chrome 600 seconds
Safari 600 seconds
Firefox 86400 seconds (24 hours)
Edge (Chromium) 600 seconds

Reverse-proxy header stripping

Reverse proxies (HAProxy, Nginx upstream, AWS ALB) sometimes strip or rewrite Access-Control-* headers set by the origin server — particularly if the proxy itself adds a conflicting Access-Control-Allow-Origin. Duplicate header injection results in a multi-value header (https://app.example.com, https://app.example.com) which browsers treat as invalid. Audit your proxy configuration to ensure headers are set in exactly one place.


DevTools + curl Verification Checklist


Common Mistakes

Issue Technical Impact Mitigation
Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true Browser returns a network error; no response data is accessible to JavaScript. Reflect the exact validated origin; never combine wildcard with credentials.
Omitting Vary: Origin CDN caches the first origin value and serves it to all subsequent requesters, blocking legitimate origins or leaking cross-tenant data. Append Vary: Origin to every CORS-enabled response unconditionally.
Setting Max-Age above 600 expecting Chrome benefit Chrome and Safari silently cap at 600 s; the extra value is ignored and can mask bugs during server restarts. Use 600 for cross-browser consistency; use 0 to disable caching during debugging.
Trailing comma in Access-Control-Allow-Headers Some browser versions parse the trailing empty entry as an unrecognised header name and reject the allowlist. Strip trailing commas; validate header strings before deployment.
Wildcard * in Access-Control-Allow-Headers with credentialed requests * is treated as a literal header name, not a glob; Authorization is not matched, and the preflight fails. List each required header name explicitly.
Setting Access-Control-Allow-Credentials: True (capital T) The spec requires the exact lowercase string true; any other casing is ignored and credentials are blocked. Always emit true in lowercase.
Duplicate headers from proxy + origin server Browser receives a multi-value header and treats it as invalid, failing origin matching. Set CORS headers in one place only — either the proxy or the origin, not both.

FAQ

Why does the browser send a preflight OPTIONS request?

Preflights verify server permissions before executing cross-origin requests that use non-simple methods (PUT, DELETE, PATCH), custom headers, or non-safelisted Content-Type values. The WHATWG Fetch Standard requires this two-round-trip handshake so servers can opt in to each capability explicitly — without it, any cross-origin page could silently trigger state-changing requests on behalf of an authenticated user.

Can Access-Control-Allow-Credentials be used with a wildcard origin?

No. The CORS specification explicitly forbids combining credentials: true with Access-Control-Allow-Origin: *. The browser will block the response and log a console error. You must reflect the exact requesting origin after validating it against a trusted allowlist, as shown in Dynamic Origin Validation Patterns.

How does Access-Control-Max-Age affect debugging?

High cache durations cause browsers to reuse stale preflight responses, masking recent header changes until the cache expires or the browser tab is closed. Set Max-Age to 0 during active debugging to force a fresh preflight on every request, then raise it to 600 for production.

Why are custom response headers invisible to JavaScript by default?

Browsers restrict access to non-safelisted response headers per the Fetch Standard’s response header filtering algorithm. Only the seven CORS-safelisted headers (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma) are visible without opt-in. Every additional header your frontend needs must appear explicitly in Access-Control-Expose-Headers.

Does the wildcard * work in Access-Control-Allow-Headers for credentialed requests?

No. When Access-Control-Allow-Credentials: true is present, * is treated as a literal header name rather than a glob. Every header the client sends in Access-Control-Request-Headers must appear by name in the allowlist. This is a common trap when migrating from non-credentialed to credentialed configurations.