ASK KNOX
beta
LESSON 253

Server-Side vs Client-Side Auth — Static Sites Can't Do OAuth

A static export has no server — no middleware, no API routes, no server-side code. That architectural fact made 8 separate OAuth implementation attempts fail. Here is why, and what actually works.

7 min read

The most important architectural decision in any web application's auth system is not which provider to use, which tokens to issue, or which library to install. It is this:

Does the server run code when a request arrives?

If yes: you can read cookies, validate sessions, redirect unauthenticated users before they see a single pixel, and participate reliably in OAuth redirect flows.

If no: you are doing auth entirely in JavaScript, after the page has loaded, with storage that is isolated per origin.

The difference sounds like a deployment detail. It is actually an architectural constraint that determines what auth patterns are physically possible.

The SSR Auth Model

In a server-side rendered Next.js application, every request passes through the server before reaching the user. Middleware runs first.

Browser Request
      ↓
Next.js Middleware (runs on every request)
      ↓  Reads HttpOnly cookie
      ↓  Validates session with Supabase
      ↓  Refreshes token if near expiry, sets new cookie
      ↓  Redirects to /login if unauthenticated
Server Component renders (user is authenticated by this point)
      ↓  Can read session from cookie directly
      ↓  Can fetch user-specific data server-side
HTML sent to browser (fully rendered, no auth flash)

The user sees authenticated content immediately. The session is validated before any HTML is generated. Cookies are HttpOnly — JavaScript cannot steal them via XSS.

The @supabase/ssr package was built for this model. It writes the Supabase session to cookies using a custom storage adapter:

// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

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)
          )
        },
      },
    }
  )

  // Refresh session if expired
  await supabase.auth.getUser()

  return response
}

Every request: session read from cookie, refreshed if needed, new cookie set in response. The user never notices session expiry.

The Static Export Model

A Next.js app with output: 'export' in next.config.ts generates a folder of HTML, CSS, and JavaScript files. There is no server. Vercel (or any CDN) serves these files directly.

When the user visits the page:

Browser Request
      ↓
CDN serves static HTML (no server code runs)
      ↓
Browser renders initial HTML (no auth state — renders as logged out)
      ↓
JavaScript bundle downloads and executes
      ↓  React hydrates
      ↓  Supabase client initializes
      ↓  Reads session from localStorage
      ↓  Discovers user is authenticated
      ↓  Re-renders in authenticated state
User sees the flash: page renders as logged out, then flips to logged in

The "flash of unauthenticated content" is structural, not fixable with CSS. It happens because the authentication state is not known until JavaScript runs.

More critically: no API routes exist in a static export. If your auth callback is /auth/callback, Next.js would normally route that to a server-side handler. In a static export, that route is a static HTML page. You cannot run exchangeCodeForSession server-side. You cannot set cookies from a server handler. You are entirely dependent on client-side JavaScript in detectSessionInUrl.

localStorage Is Per-Origin

The other constraint that makes cross-domain auth impossible with localStorage:

Origin: https://www.jeremyknox.ai
  localStorage['sb-xxxx-auth-token'] = "eyJh..."  // user logged in here

Origin: https://academy.jeremyknox.ai
  localStorage['sb-xxxx-auth-token'] = undefined   // completely separate storage

These are different origins. Browser security prevents any cross-origin localStorage access. The session stored on www is completely invisible to academy. They cannot share it. There is no workaround. It is not a bug — it is the security model.

Case Study: 8 Failed Attempts on a Static Site

Between April 1 and April 3, 2026, 8 separate attempts were made to get PKCE OAuth working on the Academy, which was deployed as a static Next.js export.

Attempt 1: Standard @supabase/ssr setup. Failed — no middleware execution in static export.

Attempt 2: Custom cookie storage adapter in the browser client. Partially worked locally (cookies were set), but the PKCE verifier was still lost on cross-origin redirects.

Attempt 3: Store the verifier in window.sessionStorage before redirect, read it back in the callback. Failed — the callback was on a different origin.

Attempt 4: Store the verifier in a cookie with Domain=.jeremyknox.ai from the client side. Failed — JavaScript cannot set the Domain attribute on cookies from client-side code. Browsers ignore document.cookie = "...; Domain=.jeremyknox.ai".

Attempt 5: Backup the verifier to localStorage with a shared key. Failed — different origin, isolated storage.

Attempt 6: Custom callback handler in pages/auth/callback.tsx that reads the URL and calls exchangeCodeForSession. Conflicted with detectSessionInUrl which was also running.

Attempt 7: Disable detectSessionInUrl, call exchangeCodeForSession manually. The verifier was gone from storage by the time the callback loaded.

Attempt 8: Store verifier in document.cookie with SameSite=None; Secure. Set, but not available on the academy subdomain because cookies set by JavaScript without a Domain attribute are scoped to the exact origin.

All 8 failed for the same root reason: the fundamental architecture was wrong. A static site cannot reliably participate in an OAuth redirect flow when the callback needs to exchange a server-side code with a verifier that was stored before the redirect.

The correct answer was to stop trying to make the static site do auth, and move auth to an SSR deployment. That architecture is Lesson 6.

The Decision Framework

ScenarioUse
Next.js SSR, one domain@supabase/ssr + middleware
Next.js SSR, multiple subdomains sharing auth@supabase/ssr + middleware + Domain=.parent.com on cookies
Static SPA, one domain, no shared auth needed@supabase/supabase-js + localStorage
Static site that needs to share auth with another siteCannot. Needs an auth hub (SSR deployment) that sets cross-domain cookies
Mobile app@supabase/supabase-js + PKCE + Secure Storage

Lesson 5 Drill

Identify a web app you maintain (or use daily). Determine which auth model it uses:

  1. Open DevTools → Application → Cookies. Is there an auth cookie set by the server?
  2. Open DevTools → Application → Local Storage. Is there an auth token stored there?
  3. If it uses cookies: are they HttpOnly? (The browser will not show the value — it shows [HttpOnly] as a placeholder)
  4. If it uses localStorage: could auth state be shared with a subdomain app? Why or why not?

If the app uses localStorage, sketch what the auth architecture would need to change to become a cookies-based SSR flow. What would need to be different?