Credential Sync Across Subdomains: CORS Preflight & Cookie Isolation
Without correctly orchestrated CORS credential headers, every cross-subdomain authenticated request fails silently: the browser blocks the response, session cookies are withheld, and the application surfaces generic 401 or network errors that give no indication whether the fault is in CORS, cookie attributes, or auth middleware ordering.
This topic sits under Server-Side CORS Configuration & Header Management, which covers the full header-set lifecycle. Here the focus is the specific intersection of credential flags, cookie domain scoping, and preflight mechanics that govern subdomain session propagation.
What breaks, and why
The WHATWG Fetch specification imposes two independent constraints that must both be satisfied before a credentialed cross-origin response is exposed to JavaScript:
Access-Control-Allow-Originmust equal the exact requesting origin string — scheme, host, port — not a wildcard.Access-Control-Allow-Credentials: truemust be present in the response.
Subdomains are separate origins under the Same-Origin Policy. app.example.com and api.example.com share a registrable domain but differ in host, so every request from one to the other is cross-origin and subject to the full CORS algorithm. Cookie mechanics add a third constraint: the cookie’s Domain attribute and SameSite policy must permit attachment to that cross-origin request independently of whether CORS headers are correct.
Any gap in this three-part contract — wrong origin reflection, missing credential flag, or misconfigured cookie attributes — breaks credential sync and can be difficult to diagnose because the browser’s error messages conflate CORS, cookie, and auth-layer failures.
Spec anchor
The behaviour described here is governed by the WHATWG Fetch “CORS protocol” algorithm, specifically:
- Section 3.2.4 — the “CORS-preflight fetch” algorithm, which determines when an OPTIONS preflight is required and what constitutes a valid preflight response.
- Section 3.2.5 — the condition
credentialsflag: when set, the algorithm checks thatAccess-Control-Allow-Originis not*before allowing the response. - RFC 6265bis —
SameSiteattribute semantics and the “cross-site” determination that governs whether cookies attach to credentialed requests.
Header and cookie attribute reference
| Name | Type | Allowed values | Default | Notes |
|---|---|---|---|---|
Access-Control-Allow-Origin |
Response header | Exact origin string | None (header absent) | Must not be * when credentials are in use |
Access-Control-Allow-Credentials |
Response header | true (only valid value) |
None | Must be present; any other value is ignored |
Vary |
Response header | Origin (among others) |
Absent | Required on all credentialed responses to prevent cache poisoning |
Access-Control-Allow-Headers |
Response header | Comma-separated header names | None | Required in preflight response for any non-safelisted request header |
Access-Control-Allow-Methods |
Response header | Comma-separated method names | None | Required in preflight response for non-safelisted methods |
Access-Control-Max-Age |
Response header | Seconds (integer) | 5 (browsers may cap) | Controls how long the preflight result is cached |
Domain (cookie attribute) |
Cookie attr | .example.com form |
Exact issuing host | Leading dot enables cross-subdomain propagation |
SameSite (cookie attribute) |
Cookie attr | None, Lax, Strict |
Lax (modern browsers) |
None required for cross-origin credentialed requests; must be paired with Secure |
Secure (cookie attribute) |
Cookie attr | Flag | Absent | Mandatory when SameSite=None; cookies rejected over plain HTTP |
HttpOnly (cookie attribute) |
Cookie attr | Flag | Absent | Prevents XSS extraction; recommended for session cookies |
Preflight flow for credentialed subdomain requests
When credentials: 'include' is set and the request is non-simple (non-safelisted method, non-safelisted headers, or a Content-Type other than the three allowed values), the browser sends an OPTIONS preflight before the actual request. The diagram below shows the complete decision path.
Step-by-step implementation
Step 1 — Validate and reflect the origin (Express/Node)
Use dynamic origin validation patterns rather than a static string comparison. A regex anchored with ^ and $ prevents substring bypass:
const ALLOWED_ORIGIN = /^https:\/\/[a-z0-9-]+\.example\.com$/;
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGIN.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
// Pass OPTIONS through immediately — auth middleware must not intercept preflights
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Requested-With');
res.setHeader('Access-Control-Max-Age', '600');
return res.sendStatus(204);
}
next();
});
The OPTIONS early-return is critical: any authentication middleware that runs before this block will reject preflights with a 401 or 403 before CORS headers are applied.
Step 2 — Nginx configuration
For Nginx acting as the CORS layer in front of an upstream:
map $http_origin $cors_origin {
default "";
"~^https://[a-z0-9-]+\.example\.com$" $http_origin;
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
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,OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type,Authorization" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
proxy_pass http://upstream_backend;
}
}
The map block mirrors the allowlist regex from Step 1. The always flag ensures headers are present on error responses (4xx, 5xx) as well — without it, a 401 from the upstream will reach the browser without CORS headers, producing a misleading “CORS error” instead of an authentication error.
Step 3 — Cookie attributes for cross-subdomain propagation
The Domain attribute must be set to .example.com (with the leading dot) to cover all subdomains. SameSite=None; Secure is required for any credentialed cross-origin request:
Set-Cookie: session_id=abc123; Domain=.example.com; Path=/; Secure; HttpOnly; SameSite=None
In Express, set this when the session is established:
res.cookie('session_id', token, {
domain: '.example.com',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'none',
maxAge: 3600000
});
Step 4 — Client-side fetch configuration
// Credentialed request — triggers preflight because Content-Type is application/json
const response = await fetch('https://api.example.com/data', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'active-sessions' })
});
// Credentialed GET — simple method but still attaches cookies
const session = await fetch('https://api.example.com/me', {
credentials: 'include'
});
credentials: 'include' is required on every request where cookies must be attached. The default credentials: 'same-origin' silently omits cookies on cross-origin calls.
Edge cases and security boundaries
Subdomain enumeration via origin reflection
Reflecting any value matching *.example.com means a compromised or attacker-controlled subdomain (e.g. evil.example.com) could receive credentials. Allowlists should enumerate the specific subdomains that legitimately need access:
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://dashboard.example.com',
'https://auth.example.com'
]);
A regex is acceptable for wildcard subdomain coverage on very large deployments but should be reviewed each time a new subdomain is provisioned.
null origin and sandboxed iframes
Requests originating from sandboxed iframes, data: URLs, or locally opened files send Origin: null. Never allowlist null as a permitted origin — it allows any sandboxed context to impersonate an authorized origin. The wildcard risks & mitigation page covers the null-origin attack surface in detail.
Opaque responses and no-cors mode
credentials: 'include' only applies in cors mode. Requests sent with mode: 'no-cors' produce opaque responses with no headers exposed to JavaScript, regardless of what the server returns. Setting credentials on an opaque request has no effect.
Mixed-content and SameSite=None
SameSite=None cookies are silently dropped if the response is delivered over plain HTTP. The Secure flag is mandatory. Deployments that terminate TLS at a load balancer and forward http:// internally must ensure the Set-Cookie is issued on the TLS-facing response, not the internal HTTP response.
Port differences
https://app.example.com and https://app.example.com:8443 are different origins. The cookie Domain=.example.com applies across ports (cookies are not port-scoped), but CORS origin matching is port-sensitive. An allowlist entry for https://app.example.com will not match https://app.example.com:8443.
Proxy and CDN interaction
Caching proxies — CDN edge nodes, Varnish, reverse proxies with response caching — must not serve a cached credential-bearing response to a different origin. Two requirements:
Vary: Originon every credentialed response — instructs the cache to key the response on theOriginheader, soapp.example.comanddashboard.example.comreceive their own cached copies.Cache-Control: privatefor truly sensitive endpoints — prevents any shared cache from storing the response at all. Use this for session endpoints and authenticated data APIs.
Omitting Vary: Origin while serving different origins from the same cache is the mechanism behind Vary: Origin header correctness failures — one origin’s cached CORS headers bleed into another origin’s request.
AWS CloudFront and Cloudflare require explicit cache policies that include Origin as a cache key for CORS responses to vary correctly. Default cache behaviours on both platforms strip Vary headers or ignore them.
DevTools and curl verification checklist
curl -sv -X OPTIONS https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
2>&1 | grep -E "Access-Control|Vary|< HTTP"
Expected output includes Access-Control-Allow-Origin: https://app.example.com, Access-Control-Allow-Credentials: true, Vary: Origin, and a 204 or 200 status.
curl -sv -X POST https://api.example.com/data \
-H "Origin: https://app.example.com" \
-H "Content-Type: application/json" \
-H "Cookie: session_id=abc123" \
-d '{"query":"test"}' \
2>&1 | grep -E "Access-Control|< HTTP"
Common mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
Access-Control-Allow-Origin: * with credentials: 'include' |
Browser blocks the response immediately; CORS error logged regardless of preflight outcome | Reflect the exact requesting origin after allowlist validation |
Auth middleware runs before CORS middleware on OPTIONS |
Preflight returns 401/403 without CORS headers; browser reports a CORS error, masking the auth failure |
Move CORS headers before auth middleware; return 204 on OPTIONS before auth runs |
Vary: Origin absent on credentialed responses |
CDN or proxy serves one origin’s CORS headers to a different origin’s request, causing sporadic CORS failures | Add Vary: Origin unconditionally on any response that sets Access-Control-Allow-Origin |
SameSite=Lax on session cookie |
Browser withholds cookie on cross-origin API requests; auth fails silently even when CORS headers are correct | Set SameSite=None; Secure for any cookie that must attach to cross-origin credentialed requests |
Domain=api.example.com instead of Domain=.example.com |
Cookie is scoped to one subdomain; does not propagate to app.example.com or other subdomains |
Always use the leading-dot form .example.com to cover all subdomains |
Wildcard Domain=.com or overly broad domain |
Cookie attaches to all *.com sites, catastrophic credential scope |
Scope Domain to the narrowest registrable domain you control |
Missing Access-Control-Allow-Headers in preflight response |
Preflight fails for any request with non-safelisted headers (Authorization, Content-Type: application/json, etc.) |
Enumerate all required headers in Access-Control-Allow-Headers on the preflight response |
FAQ
Does Access-Control-Allow-Credentials work with wildcard origins?
No. The WHATWG Fetch standard explicitly forbids combining credentials mode with Access-Control-Allow-Origin: *. Browsers will block the response and surface a console error regardless of server-side configuration.
Why do credentialed OPTIONS preflight requests return 403?
The most common cause is authentication middleware intercepting the OPTIONS request before CORS headers are applied. Auth middleware must pass OPTIONS requests through unconditionally, or the preflight will fail before the CORS layer can respond with the required headers.
How does SameSite=Lax block cross-subdomain credentialed API calls?
SameSite=Lax restricts cookie attachment to top-level navigations and safe methods. Cross-origin API requests (fetch, XHR) are not top-level navigations, so the session cookie is silently withheld even when CORS headers are fully correct.
Can I use JWTs in Authorization headers instead of cookies to avoid these constraints?
Yes. Storing JWTs in memory or localStorage and transmitting them as Authorization: Bearer <token> sidesteps cookie domain-scoping and SameSite mechanics entirely. The Authorization header is non-safelisted, so the server must include it in Access-Control-Allow-Headers, but credential attachment is then fully under application control rather than browser cookie policy.
What happens if I omit Vary: Origin on a credentialed CORS response?
Shared caches — CDN edge nodes, reverse proxies, and some browser HTTP caches — may serve a cached response bearing one origin’s Access-Control-Allow-Origin value to a request from a different origin. This causes spurious CORS failures or, worse, leaks one subdomain’s cached credential-bearing response to another.
Related
- Server-Side CORS Configuration & Header Management — parent section covering the full CORS header lifecycle
- Dynamic Origin Validation Patterns — allowlist implementation for runtime origin reflection
- Wildcard Risks & Mitigation — security implications of
*and null-origin exposure - Access-Control-* Header Directives — full reference for every CORS response header
- Handling the Vary: Origin Header Correctly — cache-keying requirements for multi-origin deployments