ASK KNOX
beta
LESSON 254

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.

7 min read

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 Lesson 5. 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.

But here is the constraint: a cookie with a Domain attribute can only be set by a server. JavaScript running in the browser can set cookies via document.cookie, but the browser ignores the Domain attribute when set from JavaScript. The cookie is scoped to the current exact origin, not the parent domain.

To set a cookie that is readable by all subdomains, you need a server-side Set-Cookie response header. This means you need an SSR deployment.

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;
                        HttpOnly; Secure

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:

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
              httpOnly: true,
              secure: true,
              sameSite: 'lax',
            })
          })
        },
      },
    }
  )

  await supabase.auth.getUser()
  return response
}

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 Lesson 5.
  • 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 www to redirect to academy.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 6 Drill

Sketch your auth architecture on paper:

  1. Which deployment is your auth hub? (Must be SSR)
  2. Where does the OAuth callback land? Is it on the hub?
  3. Which cookie domain scope do you use? (Should be parent domain)
  4. Which deployments are consumers? How do they read the session?
  5. 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.