Credentials and Cookies

If regular CORS is "why can't I fetch this," credentialed CORS is "why can't I fetch this with my cookies." And the answer is more complicated than anyone would like.

Credentials are where CORS goes from "mildly annoying" to "why is this so hard." The rules change. The wildcards stop working. Headers that were optional become mandatory. Things that worked five minutes ago stop working the moment you add credentials: "include". And the error messages — oh, the error messages — will point you in every direction except the right one.

Let's untangle this properly.


What "Credentials" Actually Means

In CORS, "credentials" is a broader term than you might expect. It covers:

  • Cookies — the most common case
  • HTTP AuthenticationAuthorization headers from browser-managed auth prompts (Basic, Digest, NTLM)
  • TLS client certificates — when mutual TLS is configured in the browser

Most of the time, when people say "credentialed CORS request," they mean cookies. But the rules apply to all three categories.


The Default: Credentials Are NOT Sent Cross-Origin

This is critical to internalize. By default, when JavaScript makes a cross-origin request, the browser does not attach cookies or other credentials. Even if the user has valid cookies for the target domain. Even if those cookies are sitting right there in the cookie jar. The browser deliberately withholds them.

// This request will NOT include cookies for api.example.com
// even if the user has them
fetch("https://api.example.com/me");

This is a security feature. Without it, any website you visit could silently make authenticated requests to your bank, your email, your corporate intranet — using your cookies. The Same-Origin Policy and CORS defaults prevent this.

If you want credentials sent cross-origin, both sides have to explicitly opt in.


Client Side: Requesting Credentials

fetch() credentials option

The fetch() API has a credentials option with three values:

ValueBehavior
"omit"Never send credentials, even for same-origin requests
"same-origin"Send credentials only for same-origin requests (default)
"include"Send credentials for both same-origin and cross-origin requests
// Cross-origin request WITH cookies
const response = await fetch("https://api.example.com/me", {
  credentials: "include"
});

That credentials: "include" is the client-side opt-in. Without it, no cookies cross the origin boundary.

XMLHttpRequest.withCredentials

The older XMLHttpRequest API uses a boolean property:

const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.example.com/me");
xhr.withCredentials = true;  // equivalent to credentials: "include"
xhr.send();

Same effect, different syntax. Both tell the browser: "Yes, I intentionally want to send credentials to this cross-origin server."


Server Side: Allowing Credentials

The client-side opt-in is necessary but not sufficient. The server must also explicitly allow credentialed requests by including this header in its response:

Access-Control-Allow-Credentials: true

If the server doesn't include this header, the browser will block the response — even if the request was sent successfully with cookies. The server received the cookies. The server processed them. The server returned a valid response. But the browser throws it all away because the server didn't say "yes, I meant to do that."

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json

{"user": "alice", "email": "alice@example.com"}

Both sides must agree. Client says credentials: "include", server responds with Access-Control-Allow-Credentials: true. If either side doesn't opt in, credentials don't flow.


THE BIG RULE: No Wildcards With Credentials

This is the rule that generates the most confusion, the most Stack Overflow questions, and the most desperate Slack messages at 2 AM. So let's state it clearly:

When Access-Control-Allow-Credentials: true is present, you CANNOT use the wildcard * for:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Expose-Headers

All four must use explicit, specific values. No shortcuts.

This will fail:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Chrome's error message:

Access to fetch at 'https://api.example.com/me' from origin 'https://app.example.com' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.

Firefox's error message:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://api.example.com/me. (Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*').

Both are saying the same thing: pick one, credentials or wildcards. You can't have both.

Why this rule exists

Think about what Access-Control-Allow-Origin: * with credentials would mean: "Any website on the internet can make authenticated requests to this server using the user's cookies and read the response." That's the entire Same-Origin Policy defeated in one header combination. The browser spec authors wisely prohibited it.

The solution: echo the origin

Instead of *, read the Origin request header, validate it against your allowlist, and echo it back:

// Express middleware
app.use((req, res, next) => {
  const allowedOrigins = [
    "https://app.example.com",
    "https://staging.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");
  }

  next();
});

Notice the Vary: Origin. This is absolutely critical here and we need to talk about why.


Vary: Origin — The Cache Safety Net

When you dynamically set Access-Control-Allow-Origin based on the request's Origin header, you're producing different responses for different origins. If anything caches this response — a CDN, a proxy, even the browser's HTTP cache — it needs to know that the cached version for origin A isn't valid for origin B.

Vary: Origin tells caches: "This response depends on the Origin request header. Cache separate copies for each origin."

Without Vary: Origin:

  1. https://app.example.com requests /api/me — server returns Allow-Origin: https://app.example.com, CDN caches it
  2. https://evil.example.com requests /api/me — CDN serves cached response with Allow-Origin: https://app.example.com
  3. Browser blocks it (origin mismatch). But now imagine the reverse:
  4. https://evil.example.com requests first — server rejects it (not in allowlist), CDN caches the rejection
  5. https://app.example.com requests — CDN serves the cached rejection
  6. Your legitimate app breaks

Both scenarios are bad. Vary: Origin prevents both. Always include it when the origin is dynamic.


The SameSite cookie attribute adds another layer of controls on when cookies are sent. It interacts with CORS in ways that confuse people because they're two separate mechanisms that both govern cookie transmission.

SameSite values:

ValueCross-origin fetch with credentials: "include"
SameSite=None; SecureCookie IS sent (this is what you need for cross-origin)
SameSite=LaxCookie is NOT sent on cross-origin fetch requests
SameSite=StrictCookie is NOT sent on any cross-site request
(not set)Defaults to Lax in modern browsers

The key insight: Even if your CORS headers are perfect, cookies with SameSite=Lax (the default) won't be sent on cross-origin fetch() requests. You need SameSite=None; Secure:

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None; Path=/

Note that SameSite=None requires the Secure attribute, which means the cookie can only be sent over HTTPS. This is a deliberate security requirement — if you're going to send cookies cross-site, you'd better be using encrypted connections.

The layers of cookie transmission for a cross-origin request:

  1. Does the JavaScript code include credentials: "include"? If no: no cookies.
  2. Does the cookie have SameSite=None; Secure? If no: no cookies.
  3. Is the request over HTTPS? If no: no cookies (because Secure flag).
  4. Does the server response include Access-Control-Allow-Credentials: true? If no: response is blocked (cookies were sent, but JS can't read the response).
  5. Does the server response include a specific (non-wildcard) Access-Control-Allow-Origin? If no: response is blocked.

All five conditions must be met. Miss one and something breaks — often with an error message that points at a different layer.

Debugging cookie transmission in DevTools:

Chrome DevTools has a great tool for this. In the Application tab, under Cookies, you can see each cookie's SameSite attribute. In the Network tab, click on a request and check the Cookies sub-tab to see which cookies were actually sent (vs. which ones exist but were withheld).

Look for the "Filtered" indicator in the cookies list. Chrome will show you cookies that were blocked and explain why (SameSite, Secure, etc.).


This is the elephant in the room. Browsers are increasingly restricting or blocking third-party cookies — cookies set by a domain other than the one in the address bar.

If your architecture looks like this:

User visits:       https://app.example.com
API calls go to:   https://api.example.com

Then cookies set by api.example.com are third-party cookies from the perspective of the page at app.example.com. Chrome, Firefox, and Safari are all tightening restrictions on these.

Safari (via Intelligent Tracking Prevention) has been blocking most third-party cookies since 2020. Firefox (Enhanced Tracking Protection) partitions them. Chrome has been experimenting with various approaches including the Privacy Sandbox and has been gradually rolling out restrictions.

What this means for credentialed CORS:

Your perfectly configured CORS setup with credentials: "include" and Access-Control-Allow-Credentials: true and SameSite=None; Secure cookies may simply stop working because the browser blocks the cookie as a third-party tracker.

Strategies for dealing with this:

  1. Use the same domain. Put your API at api.example.com and your app at app.example.com. Cookies set on .example.com are first-party cookies for both. This sidesteps the problem entirely — and also simplifies your CORS configuration.

  2. Use token-based auth instead of cookies. Store your JWT or session token in JavaScript-accessible storage and send it in the Authorization header. No cookies, no SameSite issues, no third-party cookie restrictions. (This has its own security tradeoffs — XSS can steal tokens — but it avoids cookie issues.)

  3. Use the Storage Access API. This browser API allows embedded third-party content to request access to its cookies. It requires user interaction (a click inside the iframe) and a permission prompt. It's awkward but it works for some use cases.

  4. Use CHIPS (Cookies Having Independent Partitioned State). The Partitioned cookie attribute creates per-site cookie jars. A cookie set by api.example.com while embedded in app.example.com is separate from the same cookie on other-app.example.com. This prevents cross-site tracking while allowing the credentialed flow to work.

Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None; Partitioned; Path=/

Common Mistake Walkthrough

Let's walk through the most common credentialed CORS mistake step by step, because seeing the failure mode helps you recognize it instantly.

The setup: You have a working API at api.example.com with Access-Control-Allow-Origin: *. Everything works. Then you add authentication. You need cookies. You add credentials: "include" to your fetch calls and Access-Control-Allow-Credentials: true to your server.

The frontend code:

const response = await fetch("https://api.example.com/me", {
  credentials: "include"
});
const user = await response.json();

The server response:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Content-Type: application/json

What happens: The browser blocks the response. The Console shows:

The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'.

The fix: Change * to the specific origin:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Content-Type: application/json

But then: You realize you also have a staging environment at https://staging.example.com. You can't hardcode one origin. So you implement dynamic origin reflection with validation (as shown earlier in this chapter).

But then: Your preflight requests also fail because you forgot to add Access-Control-Allow-Credentials: true to the OPTIONS response.

But then: Your cookies aren't being sent anyway because they're SameSite=Lax by default, and you need SameSite=None; Secure.

But then: Safari blocks the cookies entirely because they're third-party.

This is the credentialed CORS experience. It's turtles all the way down.


Complete Credentialed Request Flow

Let's trace a successful credentialed request from start to finish, including both the preflight and the actual request.

Scenario: https://app.example.com needs to make a PUT request to https://api.example.com/users/me with a JSON body and session cookies.

Step 1: Browser sends preflight

The PUT method with Content-Type: application/json triggers a preflight.

OPTIONS /users/me HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type

Note: The preflight itself doesn't include cookies. Credentials are not sent on preflight requests.

Step 2: Server responds to preflight

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
Vary: Origin

Step 3: Browser sends actual request (with cookies)

The preflight passed. Now the browser sends the real request, this time with cookies:

PUT /users/me HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Content-Type: application/json
Cookie: session=abc123; preferences=dark-mode

{"displayName": "Alice Updated"}

Step 4: Server responds

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-ID
Vary: Origin
Content-Type: application/json
X-Request-ID: req-456
Set-Cookie: session=abc123; Secure; HttpOnly; SameSite=None; Path=/; Max-Age=3600

{"id": "me", "displayName": "Alice Updated"}

Step 5: JavaScript can read the response

const response = await fetch("https://api.example.com/users/me", {
  method: "PUT",
  credentials: "include",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ displayName: "Alice Updated" })
});

const data = await response.json();
console.log(data.displayName);  // "Alice Updated"

// Can read exposed headers
console.log(response.headers.get("X-Request-ID"));  // "req-456"

// Cannot read unexposed headers
console.log(response.headers.get("Set-Cookie"));  // null (never exposed to JS)

Simulating this flow with curl:

# Step 1: Preflight
curl -X OPTIONS https://api.example.com/users/me \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: Content-Type" \
  -v 2>&1 | grep "< "

# Step 2: Actual request with cookies
curl -X PUT https://api.example.com/users/me \
  -H "Origin: https://app.example.com" \
  -H "Content-Type: application/json" \
  -H "Cookie: session=abc123" \
  -d '{"displayName": "Alice Updated"}' \
  -v 2>&1 | grep "< "

Decision Flowchart

When deciding how to handle credentials in your cross-origin architecture:

  1. Can you put the API on the same origin? (e.g., /api/ prefix, reverse proxy)

    • Yes: Do that. No cross-origin, no CORS, no credential drama.
    • No: Continue.
  2. Can you use the same parent domain? (e.g., app.example.com + api.example.com)

    • Yes: Use cookies on .example.com. Still cross-origin for CORS purposes, but cookies are first-party. Much simpler.
    • No: Continue.
  3. Can you use token-based auth? (JWT in Authorization header)

    • Yes: You avoid all cookie/SameSite/third-party issues. You still need CORS headers, but you don't need Allow-Credentials.
    • No: Continue.
  4. You need cross-origin cookies.

    • Set SameSite=None; Secure on your cookies.
    • Use credentials: "include" on the client.
    • Return Access-Control-Allow-Credentials: true and specific (non-wildcard) Access-Control-Allow-Origin on the server.
    • Include Vary: Origin on every response.
    • Test in Safari and Firefox, not just Chrome.
    • Accept that browser policy changes may break this in the future.
    • Have a backup plan.

Credentials and CORS is the most complex part of the CORS specification, and the part that interacts the most with other browser security mechanisms. If you can avoid credentialed cross-origin requests, avoid them. If you can't, this chapter has given you everything you need to make them work — and understand why they might stop working.