Cross-Site Cookies and the Third-Party Cookie Phase-Out

Why a perfectly configured SameSite=None cookie still fails for a large share of your users.

You have done everything right: SameSite=None; Secure, exact-origin CORS, credentials: 'include'. It works in Chrome on your machine. It fails for a real slice of your users — and you cannot fix it from the server.

Why: it is a third-party cookie

When a page on abc.com triggers a request to dfg.com, the dfg.com cookie is third-party in the browser's eyes. It is structurally identical to a tracking cookie, and browsers cannot distinguish "my API session" from "an ad network following me".

Where each browser stands

  • Safari (ITP) — blocks third-party cookies by default, and has for years. Your cross-site session simply does not exist there.
  • Firefox (Total Cookie Protection) — partitions them: dfg.com gets a separate cookie jar per top-level site. It works within abc.com, but a session established elsewhere is invisible.
  • Chrome — has been restricting third-party cookies through a long, repeatedly revised programme. Users can disable them today, and enterprise policies often already do.
  • Every browser in private/incognito mode — far more aggressive by default.

CHIPS: partitioned cookies

The Partitioned attribute ("Cookies Having Independent Partitioned State") is the sanctioned path forward. Set-Cookie: sid=…; SameSite=None; Secure; Partitioned gives your cookie a jar per top-level site. It preserves the legitimate embedded use case and removes the tracking one.

The consequence to understand: your dfg.com session cookie under abc.com is a different cookie from the one under xyz.com. If your product depends on one login being shared across several unrelated parent sites, partitioning breaks that by design.

What to do about it

  1. Move to the same site. api.abc.com, or proxy through abc.com/api. Cookies become first-party and all of this evaporates. Best option whenever you control the DNS.
  2. Use bearer tokens. No cookie, no partitioning, no blocking. Best option when the frontend genuinely cannot share a site with the API.
  3. Adopt Partitioned if you must stay cross-site with cookies, and accept per-parent-site sessions.

Test it deliberately

Chrome with third-party cookies blocked, and Safari, must be part of your test matrix — not a bug report you receive later.

Example

Example · http
# The classic cross-site session cookie — blocked in Safari, partitioned in Firefox
Set-Cookie: sid=8f2b...; HttpOnly; Secure; SameSite=None; Path=/

# CHIPS: opt in to a per-top-level-site jar
Set-Cookie: sid=8f2b...; HttpOnly; Secure; SameSite=None; Partitioned; Path=/

# Under abc.com and under xyz.com these are now two DIFFERENT cookies.
# One login no longer follows the user between unrelated parent sites.

# The alternative that avoids the whole category:
#   frontend  https://abc.com
#   API       https://api.abc.com     → same site → SameSite=Lax works normally

When to use it

  • An embedded chat widget loses its session in Safari and switches to a token held in the widget's own memory instead of a cookie.
  • A SaaS moves its API from api-provider.com to api.customer-domain.com via a CNAME so cookies are first-party for each tenant.
  • A team adds Partitioned to their widget cookie, accepting that a user must sign in separately on each host site.

More examples

Detecting the block instead of guessing

Telling the user what is wrong beats an endless spinner. A probe endpoint pair also turns 'it does not work for me' tickets into a one-line answer.

Example · javascript
// After a successful cross-site login, verify the cookie actually took.
async function loginAndVerify(email, password) {
  const login = await fetch('https://dfg.com/login', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  if (!login.ok) throw new Error('invalid_credentials');

  // The real test: does a second call see the session?
  const me = await fetch('https://dfg.com/api/me', { credentials: 'include' });

  if (me.status === 401) {
    // Login succeeded, session did not persist → the cookie was rejected.
    throw new Error(
      'Your browser is blocking third-party cookies, so we cannot keep you ' +
      'signed in. Sign in at dfg.com directly, or switch to token mode.'
    );
  }
  return (await me.json()).user;
}

// Also useful for a support page:
async function thirdPartyCookiesBlocked() {
  await fetch('https://dfg.com/cookie-probe/set', { credentials: 'include' });
  const r = await fetch('https://dfg.com/cookie-probe/check', { credentials: 'include' });
  return !(await r.json()).seen;
}

Same-site by DNS: the change that removes the problem

One CNAME removes an entire category of intermittent, browser-dependent bugs. When you control both domains, this is nearly always the right call.

Example · bash
# BEFORE — cross-site, third-party cookies, all of the above
  frontend  https://abc.com      (Vercel)
  API       https://dfg.com      (your servers)

# AFTER — same site, first-party cookies, SameSite=Lax works everywhere
  frontend  https://abc.com      (Vercel)
  API       https://api.abc.com  (CNAME → your servers / load balancer)

# DNS
api.abc.com.  300  IN  CNAME  lb-1234.eu-west-1.elb.amazonaws.com.

# Cookie becomes ordinary again
Set-Cookie: sid=8f2b...; HttpOnly; Secure; SameSite=Lax; Path=/

# CORS is still needed (different ORIGIN), but it is now the easy kind:
Access-Control-Allow-Origin: https://abc.com
Access-Control-Allow-Credentials: true

# What you no longer need: SameSite=None, Partitioned, third-party cookie
# support, or an explanation for Safari users.

Discussion

  • Be the first to comment on this lesson.