Credential Sharing & Security Boundaries in CORS
Without correct credential configuration, your API returns a network error the moment a browser tries to attach cookies or an Authorization header to a cross-origin request — even when every other CORS header is right. The browser silently strips credentials from cross-origin requests by default, and restoring them requires explicit opt-in on both client and server sides with headers that must align precisely.
This page is part of Core CORS Mechanics & Same-Origin Policy Fundamentals, the reference for how browsers enforce origin boundaries across all request types.
The Fetch Standard §3.2.5 defines the credential inclusion algorithm: a request’s credentials mode must be "include", the response must carry Access-Control-Allow-Credentials: true, and Access-Control-Allow-Origin must be a single exact origin — never *. A mismatch at any point causes the browser to block the response before JavaScript can read it.
How Browsers Enforce the Credential Boundary
The diagram below traces the full credential decision path — from client opt-in through server validation to response delivery or block.
Header Reference
These are every header and client option involved in credentialed cross-origin requests. All are required; a single missing field triggers a browser block.
| Name | Direction | Type | Allowed values | Default | Notes |
|---|---|---|---|---|---|
credentials |
Client (Fetch) | string | "omit", "same-origin", "include" |
"same-origin" |
Must be "include" to attach cookies cross-origin |
withCredentials |
Client (XHR) | boolean | true, false |
false |
XHR equivalent of credentials: 'include' |
Access-Control-Allow-Credentials |
Response | string | "true" |
absent | Any other value (including "false") is treated as absent |
Access-Control-Allow-Origin |
Response | string | exact origin or * |
absent | Must be exact origin, never *, when credentials are involved |
Vary |
Response | string | header name list | absent | Must include Origin to prevent cross-origin cache poisoning |
SameSite on Set-Cookie |
Response cookie | string | Strict, Lax, None |
Lax (modern browsers) |
Must be None; Secure for cookies to reach cross-origin requests |
Access-Control-Expose-Headers |
Response | string | comma-separated header names | absent | Required to make non-safelisted response headers readable |
Access-Control-Allow-Headers |
Preflight response | string | comma-separated header names | absent | Required for every non-safelisted request header (e.g. Authorization) |
Browser-imposed limits: Chrome caps Access-Control-Max-Age at 7200 seconds (2 hours) for credentialed flows; Firefox permits up to 86400 seconds. Safari aligns with Chrome for credentialed contexts.
Step-by-Step Implementation
1. Client opt-in
Both the Fetch API and XMLHttpRequest default to omitting credentials on cross-origin requests. Opt in explicitly:
// Fetch API
fetch('https://api.example.com/profile', {
method: 'GET',
credentials: 'include'
});
// XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/profile');
xhr.withCredentials = true;
xhr.send();
Setting credentials: 'include' alone is not sufficient. The server must respond with the correct headers or the browser blocks the response.
2. Nginx — exact origin matching with credential headers
The server must validate the incoming Origin header against an allowlist and reflect the exact value back:
# nginx.conf — credentialed CORS with allowlist
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
location /api/ {
if ($cors_origin != "") {
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-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
add_header Access-Control-Max-Age 7200 always;
add_header Vary Origin always;
return 204;
}
proxy_pass http://upstream;
}
}
The map block handles allowlist validation: only listed origins receive the credential headers; all others receive an empty value and the headers are omitted.
3. Express / Node.js — dynamic allowlist middleware
// cors-middleware.js
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com'
]);
function credentialedCors(req, res, next) {
const origin = req.headers.origin;
if (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');
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
res.setHeader('Access-Control-Max-Age', '7200');
return res.status(204).end();
}
next();
}
app.use(credentialedCors);
Set.has() performs exact string comparison. Unlike regex or includes(), it cannot be tricked by substrings or crafted origins like https://evil.app.example.com.
4. Non-simple methods trigger preflight
A POST with Content-Type: application/json or any request with Authorization triggers a preflight OPTIONS check before the main request. The preflight is sent without cookies — but the preflight response must still return Access-Control-Allow-Credentials: true, or the browser will not proceed:
// This triggers preflight because Authorization is non-safelisted
fetch('https://api.example.com/orders', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer eyJ...'
},
body: JSON.stringify({ item: 42 })
});
The browser sends OPTIONS first with Access-Control-Request-Method: POST and Access-Control-Request-Headers: authorization, content-type. Only after receiving a valid preflight response does it send the real request.
5. Cookie configuration for cross-origin delivery
Cookies must carry SameSite=None; Secure or the browser’s SameSite enforcement removes them before the request leaves the device — CORS headers never even come into play:
Set-Cookie: session=abc123; SameSite=None; Secure; HttpOnly; Path=/
SameSite=None without Secure is rejected by Chrome and Firefox. The cookie is silently dropped; no error appears in the console unless you inspect the Application tab.
Edge Cases and Security Boundaries
Subdomain normalization and trust scope
https://app.example.com and https://api.example.com are different origins. CORS does not include a subdomain wildcard mechanism — you cannot set Access-Control-Allow-Origin: *.example.com. Each subdomain must be listed explicitly in your allowlist, per origin matching rules.
Subdomain spoofing via crafted origins (https://evilapp.example.com satisfying a suffix match on .example.com) is a common attack vector when allowlist validation uses substring checks instead of exact equality.
The null origin and sandboxed iframes
Requests from sandboxed iframes (<iframe sandbox>), data: URLs, and some redirected requests carry Origin: null in the request header. Reflecting null as Access-Control-Allow-Origin: null with Access-Control-Allow-Credentials: true is dangerous: any sandboxed page on any origin can send that header and receive credentialed responses. Never allowlist null for credentialed endpoints.
Opaque responses and credential failure mode
When credentials: 'include' is set but the server returns * for Access-Control-Allow-Origin, the browser does not throw an error at the network level — it receives the response but marks it opaque and returns a network error to JavaScript. The response body is never exposed. This makes credential failures silent until you open DevTools.
Browser storage partitioning in third-party contexts
Modern browsers (Chrome 115+, Firefox 120+, Safari 17+) partition cookie storage by top-level site. A cookie set at https://api.example.com when the top-level page is https://app.example.com is invisible in a separate top-level context. This is enforced at the storage layer, below CORS. No CORS configuration can override it.
For service-to-service flows where the embedded context is a third-party iframe, bearer tokens in Authorization headers avoid this partitioning problem entirely — tokens are not subject to cookie storage partitioning. See the trade-off analysis in token vs cookie credential strategies.
Proxy and CDN Interaction
Vary: Origin is mandatory on every credentialed response. Without it, a CDN or shared reverse proxy may cache the response from origin A and serve it to origin B. Origin B receives either a blocked response (if the cached response’s Access-Control-Allow-Origin is for origin A) or, worse, origin A’s data (if the wildcard check was bypassed upstream).
CDNs handle Vary: Origin differently:
| CDN / Layer | Behaviour with Vary: Origin |
|---|---|
| Cloudflare | Respects Vary: Origin; caches per unique origin value |
| AWS CloudFront | Must add Origin to the cache policy’s allowed headers explicitly |
| Fastly | Respects Vary natively; no extra config required |
| Nginx proxy_cache | Caches on Vary key automatically when proxy_cache_valid is set |
| Varnish | Requires explicit hash_data(req.http.Origin) in VCL |
For CDN-specific configuration details see handling Vary: Origin header correctly.
Reverse proxies that strip or flatten response headers before forwarding to the client will silently remove Vary: Origin. Audit your proxy configuration to confirm headers pass through unchanged.
DevTools and curl Verification Checklist
Use this checklist after deploying credential configuration changes. Each item maps to a concrete inspector location or command output.
Verify server behaviour directly with curl:
# Simulate a preflight from your production origin
curl -si -X OPTIONS https://api.example.com/endpoint \
-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"
# Simulate a credentialed main request (cookie in jar)
curl -si https://api.example.com/endpoint \
-H "Origin: https://app.example.com" \
--cookie "session=abc123" \
| grep -i "access-control\|vary\|set-cookie"
Expected preflight output:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 7200
Vary: Origin
Any missing header in this output means the browser will block the credentialed request.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true |
Browser blocks the response immediately per Fetch Standard §3.2.5; JS receives a network error | Replace * with the exact requesting origin after allowlist validation |
Omitting Vary: Origin |
CDN or reverse proxy serves a cached response with origin A’s ACAO header to origin B, causing credential rejection or data leakage |
Add Vary: Origin unconditionally to every endpoint that reads the Origin header |
Reflecting Origin without allowlist validation |
Any site can send crafted cookies to your API endpoints and receive credentialed responses — CSRF at the CORS layer | Validate against a Set or equivalent exact-match structure; never use substring or loose regex |
SameSite=Lax cookies with credentials: include |
Cookies are stripped by the browser before leaving the device; API never sees the session; no CORS error appears in console | Set SameSite=None; Secure on cookies that must cross origins |
Caching OPTIONS responses that contain per-origin headers without Vary: Origin |
Stale preflight cached for origin A is served to origin B, causing all credential requests from B to fail | Include Vary: Origin in OPTIONS responses; set Access-Control-Max-Age conservatively during rollouts |
Allowlisting the null origin |
Any sandboxed iframe on any domain can send Origin: null and receive credentialed responses |
Remove null from allowlists; treat it as untrusted |
FAQ
Can I use a wildcard origin with credentialed CORS requests?
No. The Fetch Standard §3.2.5 is explicit: when credentials mode is "include", the browser checks that Access-Control-Allow-Origin is not *. If it is, the browser blocks the response regardless of whether Access-Control-Allow-Credentials is set. The only valid value is the exact requesting origin.
Why does my credentialed request fail even when CORS headers look correct?
The most common cause is a SameSite mismatch: cookies set without SameSite=None; Secure are silently removed by the browser before the request leaves the device, so the server never sees them and may return a 401 that has nothing to do with CORS headers. Other causes: Vary: Origin absent (CDN serving stale response for a different origin), origin string mismatch (trailing slash or http vs https), or browser storage partitioning in a third-party iframe context.
Does credentials: 'include' bypass preflight checks?
No. Credential mode has no effect on whether a preflight is required. The preflight gate is determined by the request method and headers — see simple vs preflight request classification. If the request is non-simple, the browser sends OPTIONS without cookies first. The preflight response must still declare Access-Control-Allow-Credentials: true or the browser does not proceed.
How do SameSite cookie attributes interact with CORS credential sharing?
SameSite=Lax and SameSite=Strict enforce cookie isolation at the browser’s cookie jar layer, which runs before CORS evaluation. CORS headers on the server cannot override this. For a cookie to be attached to a cross-site credentials: 'include' request, it must be set with SameSite=None; Secure. Browsers that do not support SameSite=None treat the cookie as SameSite=Strict, so older clients will not send it regardless.
Is reflecting the Origin header dynamically safe?
Only when your server validates the value against a strict allowlist before echoing it. Dynamic reflection without validation is equivalent to Access-Control-Allow-Origin: * for credentials, except the browser does not catch it. An attacker registers https://evilapp.example.com, the suffix-match regex approves it, and their page can make credentialed requests to your API. Use exact-match allowlists — a Set.has() call in Node.js, or a map block in Nginx.
Related
- Core CORS Mechanics & Same-Origin Policy Fundamentals — parent reference
- Understanding Access-Control-Allow-Credentials — deep dive on the header’s exact parsing rules
- Origin Matching Rules & Validation — how browsers evaluate the origin tuple
- Simple vs Preflight Requests — when OPTIONS fires and what credentials do to it
- Handling Vary: Origin Header Correctly — CDN cache poisoning prevention
- Credential Sync Across Subdomains — subdomain trust scope and cross-subdomain session sharing