Deep dive

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.

~8 min read

What "credentials" means in CORS

A request is a credentialed request when it carries:

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=/

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

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.