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) + setSESSION_COOKIE_DOMAIN, or (B) switch to OIDC-style redirect + per-app Bearer tokens (no shared cookie).
Current Architecture (the SSO flow we built)¶
- User hits a tenant app (e.g.
story-desk-fe) that is not logged in. - SDK runs a bootstrap probe:
GET /api/auth/sso/mewithcredentials: "include"(cookie-only, no Bearer) to ask "is there a session, and who?". - If unauthenticated, the app redirects the user to the SSO login site
(
jadu-sso-fe), which owns credential creation (login / OTP / forgot-pw). - jadu-auth BE sets the
better-auth.session_tokencookie on successful login. - 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.devis on the Public Suffix List, so each*.pages.devis a separate site — you cannot share a cookie across two*.pages.devapps even if you wanted to.
Root Cause (detailed)¶
What cookie the BE actually sets on staging¶
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
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.devcalling*.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 |
Cookie-bearing requests (credentials: 'include' / withCredentials)¶
| 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, setSESSION_COOKIE_DOMAIN=.studiojadu.com. Keeps current design; just makes cookie first-party. Reminder: aSet-CookieDomainmust be the responding host or a parent — BE must serve from understudiojadu.comfor this to be accepted. - Durable / multi-domain: Option B. Make the
/sso/mebootstrap 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.
Side-context resolved earlier this session (not the cookie issue)¶
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.