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:
- The WHATWG Fetch Standard defines CORS as an opt-in relaxation of the Same-Origin Policy, enforced by browsers on the client, not by the network on the wire.
- Every
Access-Control-*directive must appear exactly once per response; duplicate headers cause immediate rejection by all major browsers. Access-Control-Allow-Originmust contain either the literal*or a single serialized origin — regular expressions are illegal per spec and must be evaluated server-side before writing the header.- When
Access-Control-Allow-Credentials: trueis present, the origin value must be an exact echoed string;*is forbidden and browsers will block the response. Vary: Originis mandatory on every response where the server conditionally setsAccess-Control-Allow-Origin; omitting it causes CDN cache poisoning.- Preflight
OPTIONSresponses must be routed and handled before any application middleware that reads or modifies the request body. - Security boundaries for credentials (
SameSite=None; Securecookies,Authorizationheaders, TLS client certificates) require coordinated configuration at the cookie, application, and CORS layers simultaneously.
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
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
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:
- CSRF amplification: Endpoints that use cookies for authentication and return
*enable cross-origin reads of authenticated responses. Combine allowlist-based CORS with per-request CSRF tokens on mutation endpoints. - Information leakage via public metadata: Even unauthenticated endpoints may expose rate-limit counters, user identifiers in headers, or tenant slugs in JSON. Scope the allowlist to legitimate consumer origins.
- Wildcard on internal services: Internal APIs reachable from the browser (via a VPN or corporate network) should never emit
Access-Control-Allow-Origin: *; a malicious page loaded in the internal browser can initiate requests to internal endpoints.
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
- Open DevTools Network tab. Filter by
Fetch/XHRor typeoptionsin the filter to isolate preflight requests. - Inspect the OPTIONS request. Confirm
Origin,Access-Control-Request-Method, andAccess-Control-Request-Headersare present and correct. - Inspect the OPTIONS response. Verify status is
200or204. Check thatAccess-Control-Allow-Originmatches the requestOriginexactly (case-sensitive). CheckAccess-Control-Allow-Headersincludes every header the request sends. - Inspect the actual request. If the preflight passed, the browser sends the real request. Check the response again for
Access-Control-Allow-OriginandVary: Originon the actual response — preflight success does not carry over to the main response. - 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.
Related
- Access-Control-* Header Directives — full directive reference, parsing rules, and
Vary: Origincorrectness - Dynamic Origin Validation Patterns — allowlist architecture, registry caching, and subdomain validation
- Credential Sync Across Subdomains — cookie attribute matrix, subdomain isolation, and multi-tenant boundaries
- Wildcard Risks & Mitigation — threat modelling for permissive policies and automated policy scanning
- Core CORS Mechanics & Same-Origin Policy — the browser enforcement model this page’s server config must satisfy
- Preflight Request Optimization & Caching — reducing OPTIONS round-trips at scale
Topics in This Section
Dynamic Origin Validation Patterns
Runtime origin validation patterns for server-side CORS: evaluating incoming Origin headers against dynamic allowlists, with Nginx, Express.js, and edge-layer examples. Covers cache poisoning prevention, null-origin handling, and multi-tenant routing.
Credential Sync Across Subdomains
How to share authentication credentials across subdomains with CORS: preflight requirements, cookie domain scoping, SameSite constraints, and debugging session sync failures.
Wildcard CORS Risks and Safe Origin Allowlisting
Why Access-Control-Allow-Origin: * breaks credential isolation, how wildcard headers interact with preflight caching, and step-by-step patterns for replacing wildcards with safe dynamic origin validation.
Access-Control-* Header Directives
Complete reference for Access-Control-* response headers: preflight trigger conditions, credential rules, cache mechanics, and browser-specific limits — with runnable Nginx and Node.js configurations.