Deep dive

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.

· ~8 min read · Updated Oct 13, 2026

What "credentials" means in CORS

A request is a credentialed request when the browser attaches credentials it manages itself:

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:

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

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

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.