Auth is a hotel lobby. The guest shows ID at the desk, the desk hands a room card, and every door checks that card against a central list. Add a second tower with a different address and the same card stops opening doors. The card is real. The reader in the new tower does not recognize it because scope and sync were built for one building.
That is how auth breaks across subdomains and preview hosts. You build on app.example.com, you add docs.example.com or a preview like preview 123.example.com, and login fails quietly on the other. The cookie exists but is not sent. The callback is valid but rejected. The session lives but the UI shows logged out.
This post turns BYOB's real fixes into patterns you can copy, covering how listeners, cookie scope, PKCE, and forwarded host rewriting break and how to steady them.
Figure 1. One session card, many doors. When subdomains do not share cookie scope and listeners do not sync, the same user looks logged out in the next tower.
Why does login work on one subdomain and fail on another? #
The browser is the bouncer, and cookies are the wristband. A session cookie set without an explicit Domain attribute is host only. It goes to the exact hostname that set it and no other. So app.example.com can see it but docs.example.com cannot. Add SameSite and it gets more subtle. SameSite Lax allows top level navigation to carry the cookie but drops it on many cross site POST flows. SameSite Strict drops even more. SameSite None requires Secure and only works over https. None of this surfaces as a loud error. The request arrives without the cookie and the server treats a signed in user as a stranger.
Open dev tools on the failing subdomain and read Application then Cookies. If Domain is empty or pinned to app.example.com, that is host only. If SameSite is Strict and your flow relies on a POST from another subdomain, the browser will not attach the cookie. Network tells the same story. The login callback sets Set Cookie for one host, the next navigation to the other host sends no Cookie header.
Fix it deliberately. Set Domain to .example.com only when you truly need the session across subdomains under the same parent. Keep Path as / and Secure true in production. Choose SameSite Lax for most web sessions. Use None only for flows that require third party cookie style transport, and only with Secure and https. Scope narrowly, then expand with intent. If docs.example.com never needs the app session, leave it host only and issue a separate session there.
Better Auth documents session controls including expiry and update age at https://better-auth.com/docs/concepts/session-management Cloudflare documents D1 as SQLite compatible edge storage at https://developers.cloudflare.com/d1/ Either stack sits behind whatever cookie you set. The cookie is the browser contract. The session row in D1 is the server truth. Both must line up.
Public stats on how often SameSite causes auth tickets vary by stack, so we avoid a fake percentage here. What stays stable is the triage order. When auth works on one host and fails on another with no server error, check cookie scope first. The JWT decoder helps while you compare cookies across hosts.
Where does PKCE fit and why does redirect mismatch still happen? #
OAuth 2.0 is the valet ticket ritual. Your app asks the provider to authenticate the user, the provider redirects back with a one time code, your app exchanges that code for tokens, then creates a local session. RFC 6749 defines that abstract flow from authorization request to token response to protected resource. Providers enforce an exact match on redirect URIs. Scheme, host, port, path, and trailing slash all matter. A provider configured for https://app.example.com/api/auth/callback will reject https://preview-123.example.com/api/auth/callback. This is security doing its job.
PKCE binds the request to the exchange. RFC 7636 defines a verifier that stays with the starter and a challenge that travels in the redirect, with S256 as the modern method. When the code comes back, the exchanger sends the verifier to prove it started the flow. If the code leaks through logs or history, the thief still cannot swap it for tokens without the verifier. Modern guidance treats PKCE with S256 as the default even for server apps. Exact adoption varies by provider and stack, so we state the pattern rather than a quoted share.
Preview domains make the redirect match painful. Any platform that gives per branch preview hostnames will generate a new origin your provider has never seen. BYOB's generated SvelteKit code handles this before the library sees it. The auth route reads forwarded host headers, rebuilds the request URL host and protocol to the environment that actually serves the request, and only then passes control to the Better Auth handler. That sits beside the D1 Drizzle wiring that maps users sessions and accounts through drizzleAdapter with provider sqlite as documented at https://better-auth.com/docs/introduction With that rewrite, local, per branch previews, and production each resolve their own correct URLs without a spreadsheet of redirect URIs.
Figure 2. Scope decides delivery. Domain and SameSite determine whether the browser attaches the session on the next hop.
Note where multi domain traps sit on that chart. Step C fails if the redirect URI was built from a production base URL instead of the forwarded host. Step E fails if state was stored host only and the callback lands on a different host. Step F fails if the verifier was lost between redirect and exchange. Step H fails if the cookie Domain excludes the host that renders the UI. Step I fails if no listener exists to propagate the new session.
Test the full loop on the preview hostname itself. Start on preview, watch Network for the Set Cookie Domain on the callback, and confirm the next navigation sends Cookie. Do the same for magic link verify URLs.
| Trap | Why it happens | How to detect | Fix |
|---|---|---|---|
| Cookie not shared across subdomains | Cookie set host only without Domain for parent | DevTools Cookies shows no Domain, Network shows no Cookie on second host | Set Domain to parent when sharing is intended, keep Secure true, choose Lax |
| SameSite blocks callback POST | Strict or mis set Lax drops cookie on cross site hop | Login succeeds then next navigation is logged out | Use Lax for web sessions, reserve None plus Secure only where truly required |
| Redirect URI mismatch on preview | Provider allow list has production only | Provider returns invalid request on callback | Rewrite request host from forwarded headers before auth handler, separate dev and prod clients |
| Auth listener never mounted | App fetches session once but never subscribes | Successful exchange but UI stays logged out until hard reload | Mount onAuthStateChange listener in layout on mount and propagate to stores |
| PKCE verifier lost | Verifier cleared or kept host only | Invalid grant on exchange | Store verifier server side scoped to attempt, delete after single use, keep S256 |
| Clock skew expires session early | Clock off, short expiry with no tolerance | Valid login appears expired | Keep short lifetimes, allow small clock tolerance, reissue on activity |
Counts for each trap vary by stack. What stays stable is the order of suspicion. Scope before code, host before logic, listener before UI state.
Why does a successful callback still leave the user logged out? #
This is the quietest trap and the one the global listener fix addressed. Network says 200, the session row exists, the cookie is set, the user still sees the signed out header. Refresh and suddenly they are signed in. That points to missing realtime sync between the auth client and the UI.
In the BYOB front end, auth lives through managed helpers plus reactive stores. The auth module defines writable stores for user session profile and loading, a derived signed-in flag, and helpers including the auth state listener. The listener wraps the auth client's state-change subscription and pushes every event into the stores plus refreshes the profile. The initializer reads the existing session with a timeout safe path.
For a stretch the app initialized auth state but did not subscribe to later changes from the layout. The fix now lives in the root layout file. The layout imports the auth listener from the auth module and calls it on mount when running in the browser, alongside theme setup and service worker registration. Two lines, global effect.
SvelteKit documents hooks and environment helpers at https://svelte.dev/docs/kit/hooks and https://svelte.dev/docs/kit/$app-environment Layout onMount is the correct spot for a client only listener.
The lesson copies to Better Auth plus D1 stacks even though the client differs. Better Auth also lives behind a handler mounted at the framework's auth API route in SvelteKit projects, often with the Drizzle adapter against the edge database binding. The client still needs one place that listens and syncs. In SvelteKit that is layout. In other frameworks it is the top level provider that wraps the app. Mount once at the root, not per page. A per page listener looks fine in a demo and fails where callbacks fire while the page that set them no longer runs.
Quick audit for this class of bug. Open two tabs, sign out in one, watch the other. If the second tab still shows signed in until reload, you lack a listener or its store update is too narrow. Subscribe at the layout and let the auth client be the source of truth.
What does a production ready multi domain rollout checklist look like? #
Print this with the table and run it on a real preview host.
Decide which hosts share a session. Pick a parent like .example.com only for hosts that need the same login.
Cookie contract. Domain where needed, Path /, Secure true, HttpOnly true, SameSite Lax for web, None only for explicit third party flows with Secure.
Provider per environment. Separate clients for dev and prod. Register exact redirect URIs per stable environment.
Forwarded host handling. Read forwarded host and protocol headers and rebuild the request URL before the auth handler. Test with a full login started on preview.
PKCE everywhere with S256. Generate verifier per attempt, store server side, send challenge in redirect, send verifier in exchange, delete after use.
Layout listener. Mount the auth listener once at the top layout in the browser and propagate to stores. Verify cross tab sync.
Session hygiene. Short lifetimes, daily update age, rotate refresh tokens atomically, delete session on sign out. See OWASP guidance at https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html.
Magic links mirror OAuth. Verify URL must resolve to the starting host with short lived single use links.
Observability and clock. Log host and mismatch reason, alert on spikes, allow small clock tolerance.
Tooling check. Inspect tokens with the JWT decoder while debugging, never log raw tokens in prod.
What are the trade-offs? #
The post recommends scoping cookies to the parent domain, handling forwarded hosts before the auth handler, using PKCE with S256, and syncing state with a layout listener. That stack follows the Better Auth session docs, RFC 7636, RFC 6749, and SvelteKit hooks behavior covered above.
| Where the recommended path wins | Where it loses |
|---|---|
| Sessions survive subdomains and preview domains instead of dropping silently | Broader cookie scope widens CSRF surface, so flags and expiry need care |
| PKCE binds request to token exchange, which cuts code interception risk | More redirect URIs to register and keep in sync across environments |
| Layout listener keeps UI honest after callbacks on every host | Extra auth code to own and test on each route change |
Pick separate logins per subdomain, or single host auth, when the app lives on one domain and the team wants the smallest auth surface to maintain.
Who this is for (and who should skip it) #
This post is for teams that run one product across many hostnames and feel auth flake as they add subdomains or per branch previews. If you build SvelteKit apps with Better Auth plus D1 or Supabase auth with Svelte stores and you preview on ephemeral hosts, the table and checklist map directly to your next change.
It also helps builders who see redirect mismatch or a session that exists in the database while the UI shows logged out. Host awareness sits in the wrong layer, and the fix is early host resolution plus a global listener.
Skip this as a top priority if you still serve the whole app from one hostname with no preview per branch and no plans to add subdomains. Keep cookies host only and one allow listed redirect URI. If you outsource auth to a hosted provider that owns cookie and redirect handling, adapt the checklist to that provider.
One limit to know. Cookie scope and redirect lists need manual upkeep each time you add a hostname or preview pattern. A common mistake is adding a new subdomain for marketing or docs and forgetting to update the allow list, which breaks login only there.
- Best for developers shipping SvelteKit apps across subdomains and preview hosts.
- Best for startups running one product on many hostnames with shared login.
- Best for agencies managing client auth across staging and production domains.
What we learned building this #
We learned that lifecycle placement matters as much as library choice. The session records and provider wiring were correct long before the UI behaved: user and session tables mapped onto edge storage, Google sign in offered a managed broker for onboarding and project owned credentials for production trust, magic links flowed as short lived single use links, and the auth route rewrote forwarded hosts so OAuth and verify URLs resolved per environment. All that was ready and still the UI sometimes looked logged out.
The missing piece was global subscription. Mounting the auth listener once in the root layout made every page a listener rather than a snapshot: every sign-in, sign-out, and token refresh event flows into the shared session stores, while the initializer hydrates the first snapshot on load. The product intent from the user side stays the same, a single toggle that provisions app scoped credentials and expects login to work on any preview host without hand registering redirect URIs.
Copy this order when you wire multi domain auth. Database and adapter first, then host rewriting in the auth route, then PKCE with S256, then cookie scope per host, then the layout listener. Test the login callback exchange session cookie listener chain on a real preview host, not localhost alone. Done is when header, guard, and second tab agree.