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 matches https://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

Example · http
# 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: Origin

When 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.

Example · javascript
// ❌ 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.

Example · javascript
// ---------- 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

  • Be the first to comment on this lesson.