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

Three parsing details are worth committing to memory, because each one produces a failure that looks like a server bug. Header names are compared case-insensitively, so access-control-allow-origin and Access-Control-Allow-Origin are the same header — but header values are not: Access-Control-Allow-Methods: post does not authorise a POST, because methods are compared as byte sequences and browsers send them uppercase. Second, the three CORS-safelisted methods (GET, HEAD, POST) pass the preflight method check even when Access-Control-Allow-Methods omits them, which is why a policy listing only PUT still lets a GET through and hides an incomplete configuration until someone adds a PATCH. Third, Chromium normalises Access-Control-Request-Headers to lowercase and sorts it alphabetically before sending, so the string in DevTools rarely matches the casing in your client code — compare sets, not strings.


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.

Two exposure rules trip people up. Set-Cookie and Set-Cookie2 are forbidden response header names: naming them in Access-Control-Expose-Headers has no effect, because the browser never lets script read them from any response, same-origin or not. And Content-Length is already safelisted, so a client that cannot read it is failing for a different reason — usually because the response was fetched in no-cors mode and is opaque.

5. Keep the headers on error responses

CORS is evaluated before the browser hands a response to script, and that check does not care about the status code. A 401, 429 or 500 that arrives without Access-Control-Allow-Origin is blocked exactly like a missing header on a 200, so the JavaScript catch block sees a generic network error rather than the status you sent. That is why the Nginx snippet above uses always on every add_header: without it, Nginx only emits the header on 200, 201, 204, 301, 302 and 304, and every genuine error becomes an unreadable CORS failure. In Express, register the CORS middleware before the router and make sure the error handler does not construct a fresh response object that skips it.

// Error handler must not lose the CORS headers set upstream.
app.use((err, req, res, next) => {
  const origin = req.headers['origin'];
  if (origin && ALLOWED_ORIGINS.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', 'Origin');
  }
  res.status(err.status || 500).json({ error: err.code || 'internal_error' });
});

6. Answer the private-network preflight where it applies

Chromium sends an extra preflight field, Access-Control-Request-Private-Network: true, when a page on a public origin requests a resource on a private or loopback address — a dashboard on https://app.example.com calling http://192.168.1.10/api, or a hosted page calling http://localhost:8080. The preflight only succeeds if the response carries Access-Control-Allow-Private-Network: true alongside the usual Access-Control-Allow-Origin. Firefox and Safari do not send the field, so the same deployment works in one browser and fails in another with no configuration difference — a symptom often misread as a caching problem. If your product embeds a local agent or a device on the LAN, add the header on that device’s OPTIONS responder rather than loosening the public API.


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.

The asterisk therefore means two entirely different things depending on the credentials mode, one column of this matrix per mode:

What the asterisk means in each header A four-row matrix. Each row names an Access-Control header carrying an asterisk, then shows the browser's interpretation without credentials and with credentials included. Every credentialed cell degrades to a literal match or an outright block. Header carrying * Without credentials With credentials: include Access-Control-Allow-Origin any origin may read it network error, nothing readable Access-Control-Allow-Methods every method is permitted a literal name, so PUT fails Access-Control-Allow-Headers every header is permitted Authorization is rejected Access-Control-Expose-Headers all headers readable in JS X-Request-Id stays hidden Turning credentials on converts every asterisk from a wildcard into a header name spelled "*"

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.

Plotted against the clock, one cache entry has a single warm window and a hard edge at its end:

Lifetime of one preflight cache entry A time axis runs from zero to nine hundred seconds. A first call at time zero pays an OPTIONS round-trip and fills the cache. Calls within the warm window skip the preflight entirely. At the clamped Max-Age the entry is discarded and the next call preflights again. One cache entry: one origin, one URL, one method, one header set first call: OPTIONS, then the actual request at the clamped Max-Age the entry is discarded warm: every matching call skips the OPTIONS expired: it preflights again t = 0 t = 600 s t = 900 s The server sent Access-Control-Max-Age: 86400, but Chrome, Edge and Safari clamp it to 600 s Firefox honours the full value, so the same deploy behaves differently per browser Closing the tab clears the entry too — which is why a "fix" often only works in a fresh window
Browser Effective Maximum Max-Age
Chrome 600 seconds
Safari 600 seconds
Firefox 86400 seconds (24 hours)
Edge (Chromium) 600 seconds

Framework defaults worth auditing

Most production incidents in this area come from a library’s default rather than from a hand-written header. Four defaults are worth checking before you debug anything else. The Express cors package treats origin: true as “reflect whatever arrived”, which is an allowlist that allows everything — pass a function or an array instead, and note that the package only adds Vary: Origin when the origin option is dynamic. Spring’s @CrossOrigin annotation defaults maxAge to 1800 seconds, a value Chromium silently clamps to 600, so the annotation and the observed behaviour disagree by a factor of three. django-cors-headers keeps credentials off until you set CORS_ALLOW_CREDENTIALS = True, which is separate from the origin allowlist and easy to miss during a migration. And AWS API Gateway’s REST APIs answer OPTIONS from a MOCK integration with static header values, so a multi-origin allowlist has to be expressed in the integration response mapping or moved into a Lambda authorizer — the console’s “Enable CORS” button generates the wildcard version, which is incompatible with credentials. The same reflection-versus-allowlist decision is examined in depth in dynamic origin validation patterns.

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.

Do Access-Control headers have to be present on 4xx and 5xx responses?

Yes. The browser applies the CORS check before handing any response to script, and the status code plays no part in it. An error response without Access-Control-Allow-Origin is blocked exactly like a missing header on a 200, so the client sees an opaque network error instead of your status and body. In Nginx this is what the always flag on add_header is for; in application frameworks, make sure the error handler runs after the middleware that sets the headers, or re-sets them itself.