Cross-Domain Authentication — The Google Model
Google has one auth hub at accounts.google.com. A cookie on .google.com makes Gmail, Drive, and YouTube all know who you are. After 12 PRs and 8 hours of debugging, this is the architecture we built for jeremyknox.ai.
Google has one place where authentication happens: accounts.google.com.
When you log in there, Google sets a cookie on .google.com. Every Google property — Gmail (mail.google.com), Drive (drive.google.com), YouTube (www.youtube.com) — reads that cookie. One login. Every property. No re-authentication.
This is the auth hub pattern. It is the architecture that solved the jeremyknox.ai cross-domain auth problem after 12 PRs and approximately 8 hours of debugging.
Why Two Deployments Cannot Share localStorage
The failure mode is described in the previous lesson, “Server-Side vs Client-Side Auth”. localStorage is per-origin. www.jeremyknox.ai and academy.jeremyknox.ai have isolated storage. There is no mechanism — none, not even shared service workers — that allows two different origins to share localStorage.
Cookies are different. A cookie with Domain=.jeremyknox.ai is sent by the browser to ALL subdomains: www, academy, api, any subdomain that exists or will exist. The cookie lives in the browser's global cookie jar for .jeremyknox.ai, not in any one origin's isolated storage.
Here is a nuance worth getting right: JavaScript CAN set a parent-domain cookie. A page on www.jeremyknox.ai can run document.cookie = "x=1; Domain=.jeremyknox.ai" and every subdomain will see it — this is exactly how analytics tools share cookies like _ga across subdomains. Writing the cookie is not the hard part.
What JavaScript cannot do is the rest of the job: it cannot mark a cookie HttpOnly, and it cannot run the server-side half of the OAuth flow. The session cookie has to come out of a real code exchange — exchangeCodeForSession in a callback route, with the PKCE verifier in the server's custody — and @supabase/ssr writes the resulting session via server-side Set-Cookie response headers in middleware and route handlers. That flow needs a server. This is why the hub must be an SSR deployment: it runs the flow, not merely the cookie write.
The Auth Hub Architecture
www.jeremyknox.ai academy.jeremyknox.ai
(Static Next.js) (SSR Next.js — The Hub)
↓ ↓
"Login" button Handles all OAuth flows
↓ ↓ ↓
Redirect to academy /auth/callback Middleware
/auth/login exchanges code reads cookie,
↓ for session refreshes session
← ← ← ← ← ← ← ↓
Read cookie Set-Cookie:
(logged in!) sb-auth-token;
Domain=.jeremyknox.ai;
Secure; SameSite=Lax
The academy (academy.jeremyknox.ai) is the auth hub. It is an SSR Next.js application. It handles the complete OAuth flow, sets cookies with the parent domain scope, and then redirects the user back to wherever they came from.
The main site (www.jeremyknox.ai) is a static export. It cannot set cross-domain cookies, but it does not need to. It only needs to READ the cookie the hub set. createBrowserClient from @supabase/ssr reads the cookie from the browser's cookie store automatically.
Implementation: Cookie Domain Override
The key implementation detail is forcing the Domain attribute on every cookie that Supabase sets. This requires overriding the cookie handling in three places.
middleware.ts lives at the root of the Next.js app and runs on every inbound request — it is responsible for refreshing the session token silently so users are never logged out mid-visit. lib/supabase/server.ts is the shared server client used by Server Components and Route Handlers when they need to read or write auth state. app/auth/callback/route.ts is the Route Handler that receives the OAuth redirect from GoTrue and exchanges the authorization code for a session. If you add the Domain override to the callback route but skip middleware, a session refresh will re-write the cookies without the Domain attribute, silently narrowing their scope back to the subdomain — exactly the failure mode that caused PRs 7–9 in the timeline below. All three must be consistent.
1. Middleware (runs on every request to the hub)
// apps/academy/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const COOKIE_DOMAIN = '.jeremyknox.ai'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, {
...options,
domain: COOKIE_DOMAIN, // ← force parent domain scope
secure: true,
sameSite: 'lax',
})
})
},
},
}
)
await supabase.auth.getUser()
return response
}
One attribute is conspicuously absent: httpOnly. @supabase/ssr deliberately defaults its auth cookies to non-HttpOnly, and this architecture depends on that — the static www site reads the session with createBrowserClient, which is client-side JavaScript, and JavaScript cannot read HttpOnly cookies. Forcing httpOnly: true here would break every browser-based consumer. HttpOnly-everywhere is only viable when every consumer of the cookie is itself SSR; the tradeoff of the static-consumer pattern is that the session cookie is JS-readable, so XSS hygiene on every subdomain matters.
2. Server Client (for route handlers and server components)
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
const COOKIE_DOMAIN = '.jeremyknox.ai'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, {
...options,
domain: COOKIE_DOMAIN,
})
})
},
},
}
)
}
3. Auth Callback Route
// app/auth/callback/route.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const COOKIE_DOMAIN = '.jeremyknox.ai'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (!code) {
return NextResponse.redirect(new URL('/auth/error', request.url))
}
const response = NextResponse.redirect(new URL(next, request.url))
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, {
...options,
domain: COOKIE_DOMAIN,
})
})
},
},
}
)
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (error) {
return NextResponse.redirect(new URL('/auth/error', request.url))
}
return response
}
Reading the Session on the Static Site
On www.jeremyknox.ai (the static site), no server code runs. But the browser has the cookie in its cookie jar for .jeremyknox.ai. The createBrowserClient from @supabase/ssr reads it on the client side:
// Static site — client component
'use client'
import { createBrowserClient } from '@supabase/ssr'
import { useEffect, useState } from 'react'
export function useSession() {
const [session, setSession] = useState(null)
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setSession(data.session)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => setSession(session)
)
return () => subscription.unsubscribe()
}, [])
return session
}
This works because createBrowserClient reads cookies (not localStorage) by default. The .jeremyknox.ai cookie set by the academy hub is available to this client.
Case Study: 12 PRs to the Right Architecture
The April 2026 debugging timeline:
- PRs 1-3: Attempted to make the static academy site handle OAuth directly. All failed for the reasons in “Server-Side vs Client-Side Auth”.
- PRs 4-6: Switched to SSR, but cookies were not scoped to
.jeremyknox.ai. The main site still could not read them. - PRs 7-9: Added Domain attribute to cookies, but only in the callback route, not in middleware. Session refresh in middleware would re-set cookies without the Domain attribute, overwriting the correctly-scoped cookies with narrowly-scoped ones.
- PRs 10-11: Consistently applied Domain attribute in all three places (middleware, server client, callback route). Nearly worked. The main site could now read the session, but the www site's "Login" button was still triggering its own OAuth flow instead of redirecting to the academy hub.
- PR 12: Updated all "Login" links on
wwwto redirect toacademy.jeremyknox.ai/auth/login?next=https://www.jeremyknox.ai. Final architecture complete.
Total time: approximately 8 hours across two days. The root cause of all 12 PRs: not having a clear architectural model before writing code.
Lesson Drill
Sketch your auth architecture on paper:
- Which deployment is your auth hub? (Must be SSR)
- Where does the OAuth callback land? Is it on the hub?
- Which cookie domain scope do you use? (Should be parent domain)
- Which deployments are consumers? How do they read the session?
- Where does a user go when they click "Login" on each site?
If you cannot answer all 5 questions clearly, your auth architecture is underspecified. Underspecified auth architectures produce debugging sessions like the April 2026 incident.