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 Authentication —
Authorizationheaders 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:
| Value | Behavior |
|---|---|
"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-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-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:
https://app.example.comrequests/api/me— server returnsAllow-Origin: https://app.example.com, CDN caches ithttps://evil.example.comrequests/api/me— CDN serves cached response withAllow-Origin: https://app.example.com- Browser blocks it (origin mismatch). But now imagine the reverse:
https://evil.example.comrequests first — server rejects it (not in allowlist), CDN caches the rejectionhttps://app.example.comrequests — CDN serves the cached rejection- Your legitimate app breaks
Both scenarios are bad. Vary: Origin prevents both. Always include it when the
origin is dynamic.
SameSite Cookie Attribute Interaction
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:
| Value | Cross-origin fetch with credentials: "include" |
|---|---|
SameSite=None; Secure | Cookie IS sent (this is what you need for cross-origin) |
SameSite=Lax | Cookie is NOT sent on cross-origin fetch requests |
SameSite=Strict | Cookie 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:
- Does the JavaScript code include
credentials: "include"? If no: no cookies. - Does the cookie have
SameSite=None; Secure? If no: no cookies. - Is the request over HTTPS? If no: no cookies (because
Secureflag). - 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). - 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.).
Third-Party Cookie Deprecation
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:
-
Use the same domain. Put your API at
api.example.comand your app atapp.example.com. Cookies set on.example.comare first-party cookies for both. This sidesteps the problem entirely — and also simplifies your CORS configuration. -
Use token-based auth instead of cookies. Store your JWT or session token in JavaScript-accessible storage and send it in the
Authorizationheader. 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.) -
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.
-
Use CHIPS (Cookies Having Independent Partitioned State). The
Partitionedcookie attribute creates per-site cookie jars. A cookie set byapi.example.comwhile embedded inapp.example.comis separate from the same cookie onother-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:
-
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.
-
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.
- Yes: Use cookies on
-
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.
- Yes: You avoid all cookie/SameSite/third-party issues. You still need CORS
headers, but you don't need
-
You need cross-origin cookies.
- Set
SameSite=None; Secureon your cookies. - Use
credentials: "include"on the client. - Return
Access-Control-Allow-Credentials: trueand specific (non-wildcard)Access-Control-Allow-Originon the server. - Include
Vary: Originon 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.
- Set
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.