CORS with cookies and credentials: the rules that bite
Your login endpoint works in Postman. In the browser, the server sets a cookie but the next request comes back as if logged out. This is a common CORS bug, and it has four moving parts that all have to be right.
What "credentials" means in CORS
A request is a credentialed request when the browser attaches credentials it manages itself:
- Cookies for the target, or
- A TLS client certificate, or
- Browser-managed HTTP authentication (the credentials the browser remembers after a Basic auth prompt).
An Authorization header that your own code sets in fetch is not credentials in this sense. It works with Access-Control-Allow-Origin: * as long as the preflight response lists it in Access-Control-Allow-Headers. Only credentials: 'include' brings in the stricter rules below.
Those rules exist because, without them, any malicious site could call your bank's API with your cookies and read the response. The same-origin policy stops the read; CORS-with-credentials decides when to relax it.
Cross-origin is not the same as cross-site
Two terms get mixed up here, and the difference decides which cookie settings you need:
- Origin is scheme + host + port.
https://app.example.comandhttps://api.example.comare different origins, so CORS applies. - Site is scheme + registrable domain. Those same two URLs are the same site (
example.com), soSameSitecookie rules treat requests between them as same-site.
So app.example.com calling api.example.com is cross-origin but same-site: you need the CORS headers, but not SameSite=None. app.example.com calling api.another-domain.com is cross-site, and that's when SameSite=None; Secure comes in.
The four things that must align
For a cross-origin request with cookies to succeed, every one of these has to be true:
1. The client opts in via credentials: 'include'
// Default: cookies are NOT sent on cross-origin fetch
fetch('https://api.example.com/me')
// You have to explicitly ask for them
fetch('https://api.example.com/me', {
credentials: 'include'
})
For XMLHttpRequest the flag is withCredentials = true. For axios, set { withCredentials: true } on the request or instance.
2. The server returns Access-Control-Allow-Credentials: true
// On every relevant response, including the OPTIONS preflight:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
This header controls whether your page may read a credentialed response. Without it, the browser may still send the request with cookies, but it withholds the response from your code and your fetch promise rejects.
3. The server returns a specific origin, never *
The browser refuses Access-Control-Allow-Origin: * on a credentialed request. You have to echo the requesting origin:
// Bad: browser drops the response
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
// Good: origin is specific
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Send Vary: Origin whenever the value depends on the request. Without it, CDNs and browser caches can serve one origin's Access-Control-Allow-Origin to a different origin, and you get intermittent failures that are hard to debug.
4. The cookie's SameSite setting matches your setup
The cookie has to be allowed on the kind of request you're making. Chrome treats a cookie without a SameSite attribute as SameSite=Lax.
Same-site (app.example.com → api.example.com): Lax or Strict cookies are sent with credentials: 'include'. You don't need SameSite=None.
Set-Cookie: session=abc; SameSite=Lax; Secure; HttpOnly; Path=/
Cross-site (app.example.com → api.another-domain.com): Lax and Strict cookies are withheld, so the cookie needs SameSite=None, and None requires Secure:
Set-Cookie: session=abc; SameSite=None; Secure; HttpOnly; Path=/
SameSite=None: the cookie may be sent on cross-site requests. Use it only when you actually need cross-site.Secure: the cookie is only sent over HTTPS. Required withSameSite=None, and a good default anyway.HttpOnly: JavaScript can't read it. Always set this for session cookies.
Cross-site cookies are third-party cookies, and browsers increasingly restrict them regardless of SameSite=None (see the Safari note below). Keeping the app and API on the same site avoids that whole category of problems.
The full working example
App on https://app.example.com, API on https://api.example.com (same site).
Express:
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
const app = express();
app.use(cookieParser());
app.use(express.json());
app.use(cors({
origin: 'https://app.example.com',
credentials: true
}));
app.post('/login', (req, res) => {
// ... verify user
res.cookie('session', 'abc123', {
httpOnly: true,
secure: true,
sameSite: 'lax', // same-site setup; use 'none' only if the app is on another site
maxAge: 24 * 60 * 60 * 1000
});
res.json({ ok: true });
});
app.get('/me', (req, res) => {
res.json({ session: req.cookies.session });
});
Client:
// Sign in: sets the cookie
await fetch('https://api.example.com/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
// Subsequent calls: browser sends the cookie
const me = await fetch('https://api.example.com/me', {
credentials: 'include'
}).then(r => r.json());
Common failure modes
- "The value of 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true'." The server isn't sending
Access-Control-Allow-Credentials: true. See step 2. - "…must not be the wildcard '*' when the request's credentials mode is 'include'." The server is sending
Access-Control-Allow-Origin: *. Switch to a specific origin. See step 3. - Cookie shows in DevTools after login but isn't sent on subsequent requests. Check
credentials: 'include'on everyfetch, not just the login. If the app and API are on different sites, check forSameSite=None; Secureon theSet-Cookie. - Works in Chrome, fails in Safari. Safari's Intelligent Tracking Prevention blocks third-party cookies. If your API and app are on different registrable domains, the API's cookies are third-party, so Safari users may not get them at all. Moving the API under the app's registrable domain (for example
api.example.comforapp.example.com) makes the requests same-site, so the cookies are first-party. - Localhost dev.
http://localhost:5173callinghttp://localhost:3000is cross-origin but same-site, because ports don't count towards the site. ASameSite=Laxcookie works there; don't reach forSameSite=None, which also needsSecure.
If you're stuck
Open DevTools → Network → click the failing request → look at both the request and the OPTIONS that precedes it. The response headers usually show which condition failed. Walk the four-step checklist and you'll find the one that's wrong.
Calling a third-party API that needs a secret?
If the cookie dance is only there to authenticate to someone else's API, you may not need it. With our managed proxy, you store the upstream API key as a managed upstream header on your corsproxy.dev key; the proxy adds it server-side, so it never reaches the browser. Avoid putting tokens in query strings, where they end up in logs.