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.

CORS Credential Sharing Decision Flow Decision tree showing how the browser evaluates credentials: include requests through preflight, server header validation, SameSite check, and final allow or block outcome. Client request credentials: 'include' or withCredentials=true Preflight required? Yes OPTIONS preflight (no cookies sent) Server must return ACAO+ACAC Preflight valid? No BLOCKED Network error No Main request Browser attaches cookies if SameSite allows ACAO exact + ACAC: true? Yes ALLOWED JS can read response No / wildcard BLOCKED Response opaque Also required: Vary: Origin to prevent CDN cache poisoning across origins

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.

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.