Skip to content

SSO Cross-Site Cookie Issue — Context & Handoff

Purpose: Hand-off doc to start a fresh session with full context on the SSO cookie problem. Captures the symptom, the root cause, the supporting evidence (with code refs + browser-vendor docs), and the candidate fixes with a recommendation.


TL;DR

  • SSO works in Chrome / Edge but fails in Firefox (and would fail in Safari and Chrome Incognito) with a 401 on GET /api/auth/sso/me.
  • Root cause: the session cookie is a third-party (cross-site) cookie. The frontends and the auth backend live on different registrable domains, so the cookie is third-party from each app's perspective. Firefox's Total Cookie Protection partitions/blocks it; Chrome/Edge still allow it by default.
  • It is NOT a code bug. The code is correct; the domain topology is the problem.
  • Durable fix: make the cookie first-party — either (A) put everything under one shared parent domain (*.studiojadu.com) + set SESSION_COOKIE_DOMAIN, or (B) switch to OIDC-style redirect + per-app Bearer tokens (no shared cookie).

Current Architecture (the SSO flow we built)

  1. User hits a tenant app (e.g. story-desk-fe) that is not logged in.
  2. SDK runs a bootstrap probe: GET /api/auth/sso/me with credentials: "include" (cookie-only, no Bearer) to ask "is there a session, and who?".
  3. If unauthenticated, the app redirects the user to the SSO login site (jadu-sso-fe), which owns credential creation (login / OTP / forgot-pw).
  4. jadu-auth BE sets the better-auth.session_token cookie on successful login.
  5. User is redirected back to the tenant app, which re-runs /sso/me, expecting the cookie to now be present → loads the authenticated app.

The assumption that breaks: step 5 assumes the cookie set by the BE (on its own domain) will be sent on a request originating from a different app domain. That's a cross-site cookie, which browsers increasingly block.

Domains involved (staging)

  • Frontends: jadu-sso-fe.pages.dev, staging.story-desk-fe.pages.dev, etc.
  • Backend (App Runner): qwy8fgypvv.us-east-1.awsapprunner.com
  • *.pages.dev is on the Public Suffix List, so each *.pages.dev is a separate site — you cannot share a cookie across two *.pages.dev apps even if you wanted to.

Root Cause (detailed)

The staging BE runs from be/Dockerfile, which hardcodes ENV NODE_ENV=production (line 34). So staging uses the production cookie branch, not a dev branch. There is effectively no separate "staging cookie config."

SESSION_COOKIE_OPTIONS in be/src/auth/auth.helper.ts:84-95 resolves to:

Option Staging value (NODE_ENV=production) Notes
httpOnly true always
secure true NODE_ENV === 'production'
sameSite 'none' production branch
maxAge 604800000 (7 days) SESSION_EXPIRY_DAYS
path '/' always
domain omitted SESSION_COOKIE_DOMAIN unset → host-only cookie

Emitted header:

Set-Cookie: better-auth.session_token=<token>; Max-Age=604800; Path=/; HttpOnly; Secure; SameSite=None
host-only cookie bound to qwy8fgypvv.us-east-1.awsapprunner.com.

The genuine dev branch (secure:false, sameSite:'lax') only runs on localhost (NODE_ENV !== production), which staging never hits.

Why SameSite=None is necessary but NOT sufficient

  • SameSite=None = the server permits the cookie on cross-site requests.
  • The browser still decides whether to store/send a third-party cookie.
  • Cross-site is determined by registrable domain of the page vs. the API host. App on *.pages.dev calling *.awsapprunner.com = different domains = third-party.

Why Firefox blocks it but Chrome/Edge don't

  • Firefox Total Cookie Protection (on by default, FF 103+) double-keys cookies by top-level site → cookie set while on sso-fe lives in a different "cookie jar" than the one read on story-desk → not sent → 401.
  • Chrome/Edge still allow third-party cookies by default (Edge = Chromium).
  • Safari (ITP) fully blocks third-party cookies → would also fail.
  • Chrome Incognito blocks third-party cookies by default → would also fail.

So we're already broken in a large fraction of real-world sessions, not just Firefox.

Console noise to ignore (from the Firefox screenshot)

The real failure is only GET .../api/auth/sso/me → 401. Everything else is noise: - bam.nr-data.net CORS errors → New Relic telemetry blocked by Firefox tracking protection. - l.getRecordConsolePlugin is not a function → New Relic browser-agent. - dmn_chk_… cookie rejected for invalid domain → New Relic.


Code references

What File:line
BE cookie options (the source of truth) be/src/auth/auth.helper.ts:84-95
SESSION_COOKIE_DOMAIN env hook + long explainer comment be/src/auth/auth.helper.ts:54-81
NODE_ENV=production forced on staging be/Dockerfile:34
BE CORS (allows *.pages.dev, *.studiojadu.com, *.scenarix.ai) be/src/appFactory.ts:50-81
TRUSTED_ORIGINS env (only localhost currently) be/.env:18
Call site File:line Auth mechanism
SDK /sso/me bootstrap probe (the failing one) package/src/useJaduAuth.tsx:516 cookie only
sso-fe local shim http client sso-fe/lib/jaduAuth/httpClient.ts:68 cookie only
SDK generic auth client package/src/authClient.ts:90 cookie
SDK authenticated axios (tenant BE calls) package/src/authenticatedAxios.ts:145 cookie + Bearer fallback (localStorage)
jadu-auth FE api client fe/src/lib/api.ts:263 cookie + optional Bearer

Key: The two cookie-only call sites (/sso/me probe + sso-fe shim) are the ones with no Bearer fallback → they hard-fail when the cross-site cookie is withheld. The SDK already stores tokens in localStorage (package/src/tokenStorage.ts) and sends Bearer everywhere except the bootstrap probe.


Browser-vendor documentation (authoritative refs)

  • Firefox State Partitioning (technical): https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/State_Partitioning
  • "Firefox double-keys all client-side state by the origin of the resource being loaded and by the top-level site." Enabled by default FF 103+.
  • Firefox Total Cookie Protection (plain language): https://support.mozilla.org/en-US/kb/introducing-total-cookie-protection-standard-mode
  • "maintaining a separate cookie jar for each website you visit." On by default.
  • SameSite=None requires Secure / sent cross-site (MDN): https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies
  • Google: keeping third-party cookies (Apr 22, 2025): https://privacysandbox.google.com/blog/privacy-sandbox-next-steps
  • "maintain our current approach to offering users third-party cookie choice in Chrome, and will not be rolling out a new standalone prompt."
  • Google: reaffirm + retire 11 Privacy Sandbox APIs (Oct 17, 2025): https://privacysandbox.google.com/blog/update-on-plans-for-privacy-sandbox-technologies
  • Chrome keeps third-party cookies; retired Topics, Protected Audience, Attribution Reporting, IP Protection, etc.

Will Chrome eventually block like Firefox? As of Oct 2025, the opposite — Chrome kept third-party cookies AND retired most of the cookieless-replacement APIs. No near-term forced deprecation. BUT: Chrome Incognito + Safari + Firefox already block, so the fix shouldn't wait on Chrome's roadmap.


Fix Options

Option How auth is carried Cross-browser? Effort Notes
A. Shared parent domain One first-party cookie across *.studiojadu.com siblings + SESSION_COOKIE_DOMAIN=.studiojadu.com Low — code hook already exists Only works for domains under one parent we control; not *.pages.dev, not partner/second-brand domains
B. OIDC-style redirect + per-app Bearer tokens (Auth Code + PKCE) Token handed back via redirect, stored per-app, sent as Authorization: Bearer; auth server keeps its OWN first-party cookie Medium — SDK already ~80% there (tokenStorage + Bearer) Works across ANY domains; survives all browser cookie changes
C. BFF (Backend-for-Frontend) Token held server-side (e.g. Cloudflare Pages Functions); app talks to its own same-origin BE ✅ most secure High Avoids localStorage XSS risk
~~Hidden-iframe silent SSO (prompt=none)~~ 3rd-party iframe ❌ dead Iframe is third-party → also blocked. Do not use.

Important mental-model correction

Proper OIDC SSO also uses a cookie — but a first-party one on the auth server's own domain. The cross-domain SSO magic is carried by redirects + tokens, not by a shared cookie. Don't share the cookie; share the session via redirect and let each app hold its own token.

Recommendation

  • Quick unblock (single brand under one parent): Option A. Serve BE from e.g. auth.studiojadu.com, FEs from sibling subdomains, set SESSION_COOKIE_DOMAIN=.studiojadu.com. Keeps current design; just makes cookie first-party. Reminder: a Set-Cookie Domain must be the responding host or a parent — BE must serve from under studiojadu.com for this to be accepted.
  • Durable / multi-domain: Option B. Make the /sso/me bootstrap token-driven (Bearer from localStorage) instead of cookie-only, and hand a one-time code/token back on the sso-fe → app redirect.

Temporary workaround (for testing only, NOT a fix)

In Firefox, click the shield icon in the address bar → turn Enhanced Tracking Protection OFF for the site → reload. /sso/me will then carry the cookie and return 200. Proves the code works; not shippable to users.


Earlier, /sso/me was hitting a hardcoded URL https://49.jadu-auth.preview-dev.scenarix.ai. Cause: sso-fe/lib/config.ts had been hardcoded at commit aabc300 (AUTH_API_URL = 'https://49...'), which served the Production pages.dev domain. The env-based revert (commit 012edcc) was on staging but never promoted to Production. Resolved by setting jadu-sso-fe's production branch to staging, setting NEXT_PUBLIC_AUTH_API_URL, and triggering a fresh Production build (NEXT_PUBLIC_* vars are inlined at build time). The current cookie issue is separate and downstream of that fix.