CORS with Credentials
The four rules that must all hold before a browser will send a cookie to a different origin — and the wildcard that is banned.
Sending cookies (or TLS client certificates) on a cross-origin request is a credentialed request, and the browser applies stricter rules to it. All four of these must be true. Miss one and the cookie is silently dropped.
Rule 1 — the client must opt in
fetch(url, { credentials: 'include' }), or xhr.withCredentials = true. Without it the browser sends no cookies and ignores any Set-Cookie in the response.
Rule 2 — the server must opt in
Access-Control-Allow-Credentials: true on both the preflight response and the actual response. Only on the preflight is a common half-fix that still fails.
Rule 3 — no wildcard origin
Access-Control-Allow-Origin: * is forbidden with credentials. You must echo the exact requesting origin — which means keeping an allowlist and reflecting from it.
This is not a formality. Reflecting any origin plus Allow-Credentials: true means every website on the internet can read your users' authenticated responses. It is a full account-takeover vulnerability, and it usually appears as a well-meant "accept all origins" fix.
Rule 4 — the cookie itself must be cross-site capable
SameSite=None; Secure. And that turns it into a third-party cookie, which Safari blocks and Firefox partitions — see the next lesson.
Never reflect blindly
These two patterns are the vulnerability, in the two forms it usually takes:
res.set('Access-Control-Allow-Origin', req.get('origin'))with no check at all.- A sloppy suffix match:
origin.endsWith('abc.com')also matcheshttps://evil-abc.com. Compare against a set of exact strings.
The debugging trap
If a request fails only when authenticated, look at the request headers in devtools, not the response. A missing Cookie header on the outgoing request means the browser dropped it — the problem is rules 1 or 4, not your API's response.
Example
# Preflight — note credentials:true and an EXACT origin, never *
OPTIONS /api/orders HTTP/1.1
Origin: https://abc.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://abc.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 86400
Vary: Origin
# Actual request — the SAME two headers are required again here
POST /api/orders HTTP/1.1
Origin: https://abc.com
Cookie: sid=8f2b...
HTTP/1.1 201 Created
Access-Control-Allow-Origin: https://abc.com
Access-Control-Allow-Credentials: true
Vary: OriginWhen to use it
- A security audit finds an API reflecting any Origin with credentials enabled, meaning any site could read logged-in users' data from their browsers.
- A staging environment allowlist uses endsWith('abc.com') and an attacker registers evil-abc.com to bypass it.
- A login works in development on localhost but fails in production because the production cookie lacks SameSite=None and the sites differ.
More examples
The vulnerable pattern and the fix
The leading dot in '.abc.com' is doing the work: without it, evil-abc.com matches. Parsing the URL rather than string-matching removes a whole class of bypass.
// ❌ Reflect anything — every site on the internet can now read authenticated
// responses from your API using the victim's own browser session.
app.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', req.get('origin'));
res.set('Access-Control-Allow-Credentials', 'true');
next();
});
// ❌ Sloppy matching — https://evil-abc.com passes both of these
if (origin.endsWith('abc.com')) allow();
if (origin.includes('abc.com')) allow();
// ✅ Exact match against an allowlist
const ALLOWED = new Set([
'https://abc.com',
'https://www.abc.com',
'https://staging.abc.com',
]);
app.use((req, res, next) => {
const origin = req.get('origin');
if (origin && ALLOWED.has(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Credentials', 'true');
}
res.set('Vary', 'Origin');
next();
});
// ✅ If you genuinely need dynamic subdomains, parse — never substring-match
function isAllowed(origin) {
try {
const u = new URL(origin);
return u.protocol === 'https:' &&
(u.hostname === 'abc.com' || u.hostname.endsWith('.abc.com'));
} catch { return false; }
}Both halves, working end to end
This is correct and it still fails in Safari, which blocks the third-party cookie regardless of what either side sends. That limitation is the subject of the next lesson.
// ---------- API on dfg.com ----------
app.use(cors({
origin: ['https://abc.com'], // exact; never '*' with credentials
credentials: true,
allowedHeaders: ['Content-Type', 'X-XSRF-TOKEN'],
maxAge: 86400,
}));
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body);
if (!user) return res.status(401).json({ error: 'invalid_credentials' });
res.cookie('sid', await createSession(user.id, req), {
httpOnly: true,
secure: true, // mandatory with sameSite none
sameSite: 'none', // ← required because abc.com and dfg.com differ
path: '/',
maxAge: 30 * 60e3,
// NOTE: no domain option can make this cookie apply to abc.com. Impossible.
});
res.json({ user: publicProfile(user) });
});
// ---------- Frontend on abc.com ----------
await fetch('https://dfg.com/login', {
method: 'POST',
credentials: 'include', // ← required, or Set-Cookie is ignored
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
await fetch('https://dfg.com/api/orders', { credentials: 'include' });
Discussion