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:

  1. Access-Control-Allow-Origin must equal the exact requesting origin string — scheme, host, port — not a wildcard.
  2. Access-Control-Allow-Credentials: true must 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:


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.

Credentialed cross-subdomain preflight decision flow Flowchart showing the browser and server steps for a credentialed cross-subdomain CORS request: preflight OPTIONS, origin validation, cookie attachment, and final response exposure. fetch(url, { credentials: 'include' }) Non-simple request? (method / headers / content-type) No (simple) Skip preflight Yes OPTIONS /path Origin: https://app.example.com Server validates origin against allowlist Reflects exact origin + ACAO / ACAC / Vary Preflight accepted? No Request blocked Yes Actual request sent with cookies attached Response exposed to JS if ACAO/ACAC match

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.

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:

  1. Vary: Origin on every credentialed response — instructs the cache to key the response on the Origin header, so app.example.com and dashboard.example.com receive their own cached copies.
  2. Cache-Control: private for 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.