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.
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 method is not
GET,HEAD, orPOST. POSTis used with aContent-Typeother thanapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain.- Any header outside the CORS-safelisted set is present (e.g.
Authorization,X-Custom-Header). - A
ReadableStreambody is attached to the request.
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
curl -i -X OPTIONS \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Authorization, Content-Type" \ https://api.example.com/v1/resource
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.
Related
- Server-Side CORS Configuration & Header Management — parent reference covering the full header management model
- Dynamic Origin Validation Patterns — safe origin reflection with allowlist management
- Wildcard Risks & Mitigation — security analysis of
*across all CORS header types - Handling Vary: Origin Header Correctly — CDN cache segmentation and Vary interaction
- Cache Duration Tuning with Max-Age — preflight cache strategy across browsers