SameSite=None vs CORS Credentials: The Tradeoffs

You set Access-Control-Allow-Credentials: true, you reflected the exact origin, you set credentials: 'include' — and the cookie still never reaches the API. The reason is that a credentialed cross-site request must clear two independent gates: the cookie’s SameSite attribute decides whether the browser attaches the cookie to the request, and the CORS credentials rules decide whether JavaScript is allowed to read the response. Satisfying one does nothing for the other. This page is part of Credential Sync Across Subdomains, which covers keeping sessions coherent across hostnames.

Exact Symptom This Page Resolves

The request succeeds on the network but the server behaves as if the user is logged out, and the browser never surfaces a CORS error. In DevTools you observe:

Request Headers (main request to https://api.example.com/me):
  origin: https://app.example.com
  (no cookie header present)

Response Headers:
  access-control-allow-origin: https://app.example.com
  access-control-allow-credentials: true
  HTTP/2 401

The tell is the missing cookie request header paired with a perfectly valid access-control-allow-credentials: true response. CORS is configured correctly; the cookie is being withheld before the request is even sent.

Root Cause

There are two separate mechanisms, evaluated at two separate moments:

  1. The cookie gate (SameSite). When the browser builds the request, it consults each stored cookie’s SameSite attribute. Since browsers made SameSite=Lax the default, a cookie without an explicit attribute is not attached to cross-site requests. Only SameSite=None; Secure permits a cookie to travel on a cross-site request. This decision happens before any CORS logic runs.
  2. The CORS credentials gate (Access-Control-Allow-Credentials). Per the Fetch Standard, even after a credentialed response returns, the browser only exposes it to JavaScript when the response carries Access-Control-Allow-Credentials: true and a non-wildcard Access-Control-Allow-Origin. This is the same rule detailed in Understanding Access-Control-Allow-Credentials.

The two gates are orthogonal. SameSite=None with no Access-Control-Allow-Credentials means the cookie is sent but the response is hidden from JS. Access-Control-Allow-Credentials: true with a default SameSite=Lax cookie means the response would be readable — but the cookie was never attached, so the server sees an anonymous request. Both must pass. Note “site” and “origin” differ: SameSite is scoped to the registrable domain (eTLD+1), so app.example.com calling api.example.com is same-site but cross-origin — it triggers CORS yet keeps Lax cookies. It is only genuinely cross-site (e.g. app.acme.com to api.vendor.io) that SameSite=None becomes mandatory.

The two boundaries are drawn at different levels of the hostname, which is why one deployment needs SameSite=None and a superficially similar one does not:

Same site is a wider boundary than same origin Two panels compare deployments. On the left, app.example.com calling api.example.com is cross-origin so CORS applies, but same-site so a SameSite=Lax cookie is still attached. On the right, app.acme.com calling api.vendor.io is cross-origin and cross-site, so the Lax cookie is withheld and SameSite=None; Secure is required. Same site is a wider boundary than same origin One registrable domain: example.com Two registrable domains: acme.com, vendor.io https://app.example.com the page making the request https://api.example.com the API being called cross-origin: preflight and CORS headers apply same-site: a SameSite=Lax cookie is still sent https://app.acme.com the page making the request https://api.vendor.io the API being called cross-origin: preflight and CORS headers apply cross-site: Lax is withheld, None; Secure needed SameSite is keyed to the registrable domain; CORS is keyed to scheme, host and port

Prerequisite State

Before applying the fix, confirm:

The Two Gates

The two independent credential gates A horizontal flow showing a cross-site request passing through the SameSite cookie gate before being sent, then the response passing through the Access-Control-Allow-Credentials gate before JavaScript can read it. Both gates must pass. Page JS include mode Gate 1: SameSite None; Secure? attaches cookie Server reads session Gate 2: ACAC exposes resp Both gates are independent — both must pass controls the REQUEST controls the RESPONSE
Figure — the SameSite gate governs the outgoing cookie; the CORS credentials gate governs response exposure.

Step-by-Step Fix

The cookie will not travel cross-site without it. Add HttpOnly so JavaScript cannot read it, and scope it deliberately.

Set-Cookie: session=abc123; SameSite=None; Secure; HttpOnly; Path=/; Max-Age=3600

In Express, when issuing the cookie:

res.cookie('session', token, {
  sameSite: 'none',   // permit cross-site attachment
  secure: true,       // mandatory whenever sameSite is 'none'
  httpOnly: true,
  path: '/',
  maxAge: 3600 * 1000
});

Step 2 — Keep the CORS credentials gate correct

The cookie now rides the request, but the response is still hidden from JS unless CORS agrees. Reflect the exact origin (never *) and set the credentials header on the preflight and the real response.

const ALLOWED = new Set(['https://app.acme.com']);
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (origin && ALLOWED.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Vary', 'Origin');
  }
  next();
});

Step 3 — Opt the client into credentials

await fetch('https://api.vendor.io/me', {
  credentials: 'include' // default 'same-origin' omits cookies cross-site
});

Step 4 — Add a CSRF defense to offset the widened surface

Because SameSite=None restores the ability for any site to trigger cookie-bearing requests, add an independent token the attacker cannot read. A double-submit pattern works well: issue a non-HttpOnly CSRF cookie plus a matching request header, and reject on mismatch.

app.use((req, res, next) => {
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
    if (req.headers['x-csrf-token'] !== req.cookies['csrf']) {
      return res.status(403).send('CSRF token mismatch');
    }
  }
  next();
});

SameSite=None vs Same-Site Architecture vs Token Headers

Approach Cross-site cookie sent? CSRF surface When to prefer
SameSite=None; Secure cookie + CORS credentials Yes Wide — any site can trigger cookie-bearing requests; needs anti-CSRF token Front end and API are on genuinely different registrable domains and you must use cookies
Same-site architecture (shared eTLD+1, SameSite=Lax) Yes, automatically Narrow — Lax blocks cross-site top-level POSTs and subresource requests You control DNS and can host app + API under one registrable domain
Bearer token in Authorization header No cookie at all Minimal — no ambient authority; token must be sent explicitly SPA or mobile client; you can store and attach a token safely

Verification

Inspect the cookie attributes and confirm the cookie is attached to the cross-site request:

curl -si https://api.vendor.io/login -X POST \
  -H "Origin: https://app.acme.com" \
  -d 'user=demo&pass=demo' | grep -i "set-cookie"

Expected — the cookie advertises both attributes:

set-cookie: session=abc123; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=None

Then in DevTools:

Security Boundary Note

Do not reach for SameSite=None as a reflex. It disables the browser’s built-in cross-site request defense for that cookie, and CORS does not back-fill that protection: CORS gates who can read a response, never who can send a state-changing request. A malicious page can still cause the browser to fire a cookie-bearing POST to your API — the attacker just cannot read the reply. Every SameSite=None cookie must therefore be paired with an explicit anti-CSRF mechanism (double-submit token or a strict server-side Origin check). When you can instead keep app and API under one registrable domain, do that: SameSite=Lax gives you cross-origin cookies with a far narrower attack surface.

Trace a forged transfer through the browser to see exactly which half of the attack CORS actually interrupts:

What CORS interrupts in a forged cross-site write A page on evil.example fires a credentialed POST. The browser attaches the SameSite=None cookie and the API accepts the session, so the transfer executes and the state change is committed. Only the response body is hidden from the attacker's script, which is why an anti-CSRF token is still required. SameSite=None re-opens the send path; CORS only guards the read path fetch POST, credentials: include cookie rides along https://evil.example the attacker's page Browser attaches the None cookie api.vendor.io the session looks valid The transfer executes the state change is committed CORS hides the response body the attacker cannot read the reply CORS decides who may read the response CORS does not decide whether the request is sent at all Only a double-submit token or a server-side Origin check stops the middle step

Common Mistakes

Issue Why It Fails
Setting SameSite=None without Secure Browsers reject the cookie outright; it is never stored, so no cookie is ever sent.
Assuming Access-Control-Allow-Credentials: true makes the cookie get sent That header only exposes the response to JS. Cookie attachment is decided earlier by SameSite; a default Lax cookie is withheld regardless.
Using SameSite=None when app and API share a registrable domain Unnecessarily widens the CSRF surface. Those requests are same-site — Lax cookies are sent automatically.
Shipping SameSite=None cookies with no CSRF token or Origin check Any site can now trigger cookie-authenticated state changes; CORS blocks reads, not the request itself.

FAQ

If Access-Control-Allow-Credentials is true, why is my cookie still not sent cross-site?

Access-Control-Allow-Credentials only governs whether the browser exposes a credentialed response to JavaScript. Whether the cookie is attached to the outgoing request is decided earlier and independently by the cookie’s SameSite attribute. A cookie set with the default SameSite=Lax is never attached to a cross-site request, so the server sees no cookie regardless of the CORS header. You must set SameSite=None; Secure on the cookie itself.

Does SameSite=None make my application vulnerable to CSRF?

SameSite=None removes the browser’s default cross-site request protection, so any site can cause the cookie to ride along on requests to your API. CORS does not stop the request from being sent or state-changing side effects from occurring; it only controls read access to the response. Pair SameSite=None cookies with an explicit anti-CSRF defense such as a double-submit token or an Origin header check on the server.

When should I avoid SameSite=None entirely?

If your front end and API can share a registrable domain (for example app.example.com and api.example.com under example.com), the requests are same-site and SameSite=Lax cookies are sent automatically without widening the CSRF surface. Alternatively, switch to a bearer token sent in an Authorization header, which is not subject to SameSite at all and avoids ambient cookie authority.