CORS with cookies and credentials: the rules that bite
Your login endpoint works in Postman. In the browser, it sets a cookie on the server but the next request comes back as if logged out. This is the most common CORS bug, and it has exactly four moving parts. Get all four right or it doesn't work.
What "credentials" means in CORS
A request is a credentialed request when it carries:
- Cookies for the target domain, or
- An
Authorizationheader, or - A TLS client certificate.
For cross-origin credentialed requests, browsers enforce extra rules on top of regular CORS. The reason: without those rules, 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 override that.
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
Without this header, the browser sees the response, sees that the client asked for credentials, and silently drops the response. Your fetch promise rejects.
3. The server returns a specific origin — never *
The browser refuses the combination of Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. 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
The Vary: Origin header is non-optional — without it, CDNs and browser caches will serve the wrong Access-Control-Allow-Origin value to a different origin and you'll get intermittent failures that are very hard to debug.
4. The cookie itself is set correctly
Even if everything above is right, modern browsers will refuse to send a cookie cross-origin unless it has both:
Set-Cookie: session=abc; SameSite=None; Secure; HttpOnly; Path=/
SameSite=None— tells the browser the cookie may be sent on cross-origin requests. The default isSameSite=Laxin Chrome, which refuses to send on most cross-origin POSTs.Secure— required whenSameSite=None. Cookie only sent over HTTPS.HttpOnly— JavaScript can't read it. Always set this for session cookies.
The full working example
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: 'none',
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'." — Server isn't sending
credentials: true. See step 2. - "…must not be the wildcard '*' when the request's credentials mode is 'include'." — 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. Almost always missing
SameSite=None; Secureon theSet-Cookieresponse. Or you forgotcredentials: 'include'on the secondfetch. - Works in Chrome, fails in Safari. Safari's Intelligent Tracking Prevention is more aggressive with third-party cookies. If your API and app are different registrable domains (not just subdomains), Safari users may have cookies blocked entirely. Consider moving the API under a subdomain of the app.
- Localhost dev fails.
Securecookies require HTTPS. Either run dev on HTTPS (Vite supports it) or relax in dev by usingSameSite=Laxwhen not on production.
If you're stuck
Open DevTools → Network → click the failing request → look at both the request and the OPTIONS that precedes it. Eight times out of ten, the answer is in the response headers. Walk the four-step checklist and you'll find the one that's wrong.
Avoid the credentials problem entirely
If you can use a bearer token in the request body or a query parameter instead of a cookie, you sidestep the SameSite/Secure rabbit hole. Our managed proxy uses X-API-Key for exactly this reason.