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:
- The cookie gate (
SameSite). When the browser builds the request, it consults each stored cookie’sSameSiteattribute. Since browsers madeSameSite=Laxthe default, a cookie without an explicit attribute is not attached to cross-site requests. OnlySameSite=None; Securepermits a cookie to travel on a cross-site request. This decision happens before any CORS logic runs. - 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 carriesAccess-Control-Allow-Credentials: trueand a non-wildcardAccess-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.
Prerequisite State
Before applying the fix, confirm:
- The page origin and API are on different registrable domains (truly cross-site). If they share an eTLD+1, prefer the same-site path in the tradeoffs table below instead of
SameSite=None. - Cross-origin CORS is already correct: the API reflects the exact
Originand setsAccess-Control-Allow-Credentials: trueon both theOPTIONSpreflight and the actual response. Wildcard origins are incompatible with credentials — see the parent pillar, Server-Side CORS Configuration & Header Management. - The API is served over HTTPS.
SameSite=Noneis ignored (cookie rejected) unless theSecureattribute is also present.
The Two Gates
Step-by-Step Fix
Step 1 — Set SameSite=None; Secure on the session cookie
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.
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.