< back to cookbook
RECIPE 05

Cross-App Auth on Cloudflare with Better Auth

One login across multiple Cloudflare apps. A shared worker handles identity, sessions, and invites. Each app resolves its own user profile.

May 3, 2026

What you're building

One Worker handles login, password resets, session cookies, and invite onboarding. Every app on the domain validates against that one service. Each app keeps its own users table with its own roles. A CLI manages users across all of them from one place.

The central session cookie is set on .example.com so every subdomain receives it. But apps don't call GET /session on every request — that would make ordinary page loads depend on auth availability. Instead, each app mints its own short-lived app-local cookie after a successful SSO validation. Normal navigation uses only the app-local cookie. /session is a bootstrap fallback, called when the app-local cookie is missing or expired.

Why Better Auth? Open-source TypeScript that runs in Workers. Handles password hashing, session tokens, cookie signing, and expiry out of the box.

Prerequisites

  • A Cloudflare account with a domain (you need subdomains for each app)
  • A Neon PostgreSQL database (free tier works)
  • wrangler CLI installed and authenticated
  • node and npm

Architecture

Three pieces: the auth schema in Postgres, the auth worker, and the per-app integration.

Better Auth expects camelCase column names ("userId", "expiresAt") — use them exactly. Each app gets its own users table with an auth_user_id pointing to auth.user.id. That column is the only connection between the shared identity and app-level roles.

Create the auth schema

sqlCREATE SCHEMA IF NOT EXISTS auth;

CREATE TABLE IF NOT EXISTS auth."user" (
  id                TEXT        PRIMARY KEY,
  name              TEXT        NOT NULL,
  email             TEXT        NOT NULL UNIQUE,
  "emailVerified"   BOOLEAN     NOT NULL DEFAULT FALSE,
  image             TEXT,
  "createdAt"       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  "updatedAt"       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS auth.session (
  id            TEXT        PRIMARY KEY,
  "expiresAt"   TIMESTAMPTZ NOT NULL,
  token         TEXT        NOT NULL UNIQUE,
  "createdAt"   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  "updatedAt"   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  "ipAddress"   TEXT,
  "userAgent"   TEXT,
  "userId"      TEXT        NOT NULL REFERENCES auth."user"(id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS auth.account (
  id                        TEXT        PRIMARY KEY,
  "accountId"               TEXT        NOT NULL,
  "providerId"              TEXT        NOT NULL,
  "userId"                  TEXT        NOT NULL REFERENCES auth."user"(id) ON DELETE CASCADE,
  "accessToken"             TEXT,
  "refreshToken"            TEXT,
  "idToken"                 TEXT,
  "accessTokenExpiresAt"    TIMESTAMPTZ,
  "refreshTokenExpiresAt"   TIMESTAMPTZ,
  scope                     TEXT,
  password                  TEXT,
  "createdAt"               TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  "updatedAt"               TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS auth.verification (
  id            TEXT        PRIMARY KEY,
  identifier    TEXT        NOT NULL,
  value         TEXT        NOT NULL,
  "expiresAt"   TIMESTAMPTZ NOT NULL,
  "createdAt"   TIMESTAMPTZ DEFAULT NOW(),
  "updatedAt"   TIMESTAMPTZ DEFAULT NOW()
);

Better Auth's core schema. The camelCase column names are required — Better Auth generates queries using these exact strings. Don't rename them.

Add an invite table

Better Auth has no built-in invite flow. Add a custom table:

sqlCREATE TABLE IF NOT EXISTS auth.invite (
  id            TEXT        PRIMARY KEY,
  email         TEXT        NOT NULL,
  token_hash    TEXT        NOT NULL UNIQUE,
  expires_at    TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
  accepted_at   TIMESTAMPTZ,
  revoked_at    TIMESTAMPTZ,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Only the hash is stored. The raw token lives in the URL only. A leaked database doesn't expose pending invites.

Build the auth worker

Project setup

bashmkdir auth && cd auth
npm init -y
npm install hono better-auth @neondatabase/serverless
npm install -D wrangler @cloudflare/workers-types typescript tsx

Better Auth configuration

ts// auth.config.ts
import { betterAuth } from 'better-auth'
import { Pool } from '@neondatabase/serverless'

export function createAuth(
  authDatabaseUrl: string,
  secret: string,
  baseURL: string
) {
  const isLocal = baseURL.startsWith('http://localhost')

  return betterAuth({
    secret,
    baseURL,
    database: (() => {
      const pool = new Pool({ connectionString: authDatabaseUrl })
      pool.on('connect', (client: any) => client.query('SET search_path = auth'))
      return pool
    })(),

    session: { cookieCache: { enabled: false } },

    advanced: {
      useSecureCookies: !isLocal,
      crossSubDomainCookies: isLocal
        ? { enabled: false }
        : { enabled: true, domain: '.example.com' },
    },

    trustedOrigins: [
      'https://auth.example.com',
      'https://app1.example.com',
      'https://app2.example.com',
      ...(isLocal ? ['http://localhost:8787', 'http://localhost:8788'] : []),
    ],

    emailAndPassword: {
      enabled: true,
      requireEmailVerification: false,
    },
  })
}

Key decisions:

  • SET search_path = auth on every connection. Better Auth looks for its tables at the top of the search path. This keeps auth in its own schema without touching table prefix config.
  • crossSubDomainCookies sets the cookie domain to .example.com so every subdomain gets it.
  • cookieCache: { enabled: false } forces a DB hit on every session check. With multiple apps sharing a session, a stale cache can let a logged-out user through. Skip it.
  • useSecureCookies adds the __Secure- prefix and Secure flag in production. Off on localhost.

Worker routes

The auth worker is a Hono app. Seven routes:

ts// workers/auth.ts
import { Hono } from 'hono'
import { createAuth } from '../auth.config'

interface Env {
  AUTH_DATABASE_URL: string
  BETTER_AUTH_SECRET: string
  BETTER_AUTH_URL: string
}

const app = new Hono<{ Bindings: Env }>()

// Health check
app.get('/health', (c) => c.text('ok'))

// Block self-registration
app.post('/api/auth/sign-up/email', (c) =>
  c.json({ error: 'Self-registration is disabled.' }, 403)
)

// Better Auth API passthrough (sign-in, sign-out, get-session, etc.)
app.on(['GET', 'POST'], '/api/auth/*', (c) => {
  const auth = createAuth(c.env.AUTH_DATABASE_URL, c.env.BETTER_AUTH_SECRET, c.env.BETTER_AUTH_URL)
  return auth.handler(c.req.raw)
})

// Login page — redirects immediately if session exists; otherwise renders form
app.get('/login', async (c) => { /* check session → redirect or render form */ })
app.post('/login', async (c) => { /* validate + set cookie + redirect */ })

// Logout
app.get('/logout', (c) => { /* render confirmation */ })
app.post('/logout', async (c) => { /* clear session + redirect */ })

// Invite acceptance
app.get('/accept-invite', async (c) => { /* render form */ })
app.post('/accept-invite', async (c) => { /* create account + sign in */ })

export default app

Login flow

The POST handler is where the cookie gets set. The worker calls Better Auth's sign-in endpoint internally and forwards the Set-Cookie headers to the browser:

tsapp.post('/login', async (c) => {
  const body = await c.req.parseBody()
  const email = (body.email as string || '').trim()
  const password = body.password as string || ''
  const redirect = getSafeRedirect(body.redirect as string, c.env.BETTER_AUTH_URL)

  if (!email || !password) {
    return c.html(renderLoginForm(redirect, 'Email and password are required'), 400)
  }

  const auth = createAuth(c.env.AUTH_DATABASE_URL, c.env.BETTER_AUTH_SECRET, c.env.BETTER_AUTH_URL)
  const signInRes = await auth.handler(
    new Request(`${c.env.BETTER_AUTH_URL}/api/auth/sign-in/email`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })
  )

  if (!signInRes.ok) {
    return c.html(renderLoginForm(redirect, 'Invalid email or password'), 401)
  }

  // Forward the session cookie to the browser
  const response = new Response(null, { status: 302, headers: { Location: redirect } })
  const cookies: string[] = (signInRes.headers as any).getAll('Set-Cookie')
  for (const cookie of cookies) {
    response.headers.append('Set-Cookie', cookie)
  }
  return response
})

Redirect validation

Validate every redirect. Only your own domain is allowed:

tsfunction isAllowedRedirect(url: string, baseURL: string): boolean {
  try {
    const parsed = new URL(url)
    if (baseURL.startsWith('http://localhost') && parsed.hostname === 'localhost') return true
    if (parsed.protocol !== 'https:') return false
    return parsed.hostname === 'example.com' || parsed.hostname.endsWith('.example.com')
  } catch {
    return false
  }
}

Security headers

Add security headers to every response:

tsapp.use('*', async (c, next) => {
  await next()
  c.res.headers.set('Cache-Control', 'no-store')
  c.res.headers.set('X-Content-Type-Options', 'nosniff')
  c.res.headers.set('Referrer-Policy', 'no-referrer')
  if (c.res.headers.get('Content-Type')?.startsWith('text/html')) {
    c.res.headers.set('Content-Security-Policy',
      "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'"
    )
  }
})

Skip form-action in the CSP. Chrome blocks same-origin form POSTs when form-action 'self' is present, even from the same Worker. Leave it out.

Deploy

bashnpx wrangler secret put AUTH_DATABASE_URL    # Neon connection string
npx wrangler secret put BETTER_AUTH_SECRET   # random secret for signing sessions
npx wrangler deploy

The session cookies

Two cookies are in play. The auth worker owns the central one; each app owns its own.

Central cookie (Better Auth)

AttributeProductionDevelopment
Name__Secure-better-auth.session_tokenbetter-auth.session_token
Domain.example.com(host only)
HttpOnlytruetrue
Securetruefalse
SameSiteLaxLax
Expiry7 days7 days

Apps forward this cookie verbatim to /session and never parse it directly.

App-local cookie

AttributeValue
Name__Host-<app>_app_session
Domain(none — host-only via __Host- prefix)
HttpOnlytrue
Securetrue
SameSiteLax
Path/
Expiry12 hours

The __Host- prefix forces host-only scope — no Domain attribute, Path=/ required, Secure required. It cannot be sent cross-subdomain. The payload is a signed JSON blob:

json{ "id": "auth-user-id", "email": "user@example.com", "name": "User Name", "iat": 1778080000, "exp": 1778123200, "nonce": "random" }

The nonce prevents replay if a token value is observed. The id field is used as auth_user_id to look up the app profile — no /session call needed.

Integrate your apps

Each app needs: a users table with auth_user_id, session validation, user resolution, and login/logout wiring.

Add auth_user_id to your users table

sqlCREATE TABLE users (
  id             SERIAL PRIMARY KEY,
  auth_user_id   TEXT UNIQUE,
  name           TEXT NOT NULL,
  email          TEXT NOT NULL UNIQUE,
  role           TEXT NOT NULL DEFAULT 'viewer',
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

auth_user_id is the link between your app's user record and the shared identity. Each app defines its own roles. The auth service doesn't know about them.

Validate sessions

Every protected route runs this logic:

incoming request
  → validate __Host-<app>_app_session (HMAC + expiry)
  → hit:  use payload.id as auth_user_id, look up app profile, serve
  → miss: call GET /session with the central Better Auth cookie
      200 → mint app-local cookie, look up app profile, serve
      401 → clear app-local cookie, redirect to login
      5xx / timeout / bad JSON → show auth-unavailable page, do not redirect

Sign and verify the app-local cookie

Use WebCrypto HMAC-SHA256 (available in Workers and modern runtimes):

tsconst COOKIE_NAME = '__Host-app_app_session'
const SESSION_TTL = 12 * 60 * 60 // 12 hours

async function mintAppLocalSession(user: AuthUser, secret: string): Promise<string> {
  const payload = JSON.stringify({
    id: user.id, email: user.email, name: user.name,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + SESSION_TTL,
    nonce: crypto.randomUUID(),
  })
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
  )
  const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
  const sigHex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join('')
  return `${btoa(payload)}.${sigHex}`
}

async function parseAppLocalSession(cookies: string | null, secret: string): Promise<AppSession | null> {
  if (!cookies) return null
  const match = cookies.match(new RegExp(`${COOKIE_NAME}=([^;]+)`))
  if (!match) return null
  try {
    const [b64, sig] = match[1].split('.')
    const data = atob(b64)
    const key = await crypto.subtle.importKey(
      'raw', new TextEncoder().encode(secret),
      { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']
    )
    const sigBytes = Uint8Array.from(sig.match(/.{2}/g)!.map(h => parseInt(h, 16)))
    const valid = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(data))
    if (!valid) return null
    const session = JSON.parse(data) as AppSession
    if (session.exp < Math.floor(Date.now() / 1000)) return null
    return session
  } catch {
    return null
  }
}

function appLocalCookieHeader(value: string): string {
  return `${COOKIE_NAME}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL}`
}

function clearAppLocalCookieHeader(): string {
  return `${COOKIE_NAME}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`
}

For Ruby, use OpenSSL::HMAC:

rubyCOOKIE_NAME = '__Host-k12_app_session'
SESSION_TTL = 12 * 60 * 60

def self.mint_app_session(user, secret)
  payload = {
    id: user['id'], email: user['email'], name: user['name'],
    iat: Time.now.to_i, exp: Time.now.to_i + SESSION_TTL,
    nonce: SecureRandom.uuid
  }.to_json
  sig = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
  "#{Base64.strict_encode64(payload)}.#{sig}"
end

def self.parse_app_session(cookies, secret)
  return nil unless cookies
  match = cookies.match(/#{Regexp.escape(COOKIE_NAME)}=([^;]+)/)
  return nil unless match
  b64, sig = match[1].split('.')
  return nil unless b64 && sig
  payload = Base64.strict_decode64(b64)
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
  return nil unless OpenSSL.fixed_length_secure_compare(sig, expected)
  data = JSON.parse(payload)
  return nil if data['exp'] < Time.now.to_i
  data
rescue
  nil
end

Call /session (the fallback)

/session is called only when the app-local cookie is missing or expired. The return value must distinguish 401 (user is not authenticated) from 5xx (auth infrastructure is down). They require different responses.

Cloudflare Worker (same zone) — use a Service Binding

A Worker calling another Worker on the same zone by its public hostname gets HTTP 522. Same-zone subrequests via the public edge are blocked. Use a Service Binding instead:

jsonc{
  "services": [
    { "binding": "AUTH_SERVICE", "service": "auth-worker" }
  ]
}
tstype SessionOutcome = 'authenticated' | 'unauthenticated' | 'error'

export async function verifySharedSession(env: Env, headers: Headers): Promise<{
  user: AuthUser | null
  headers: Headers
  outcome: SessionOutcome
}> {
  const cookie = headers.get('Cookie')
  const url = `${env.AUTH_BASE_URL ?? 'https://auth.example.com'}/session`
  try {
    const response = env.AUTH_SERVICE
      ? await env.AUTH_SERVICE.fetch(url, { method: 'GET', headers: { Accept: 'application/json', ...(cookie ? { Cookie: cookie } : {}) } })
      : await fetch(url, { method: 'GET', headers: { Accept: 'application/json', ...(cookie ? { Cookie: cookie } : {}) } })

    if (response.status === 401) return { user: null, headers: response.headers, outcome: 'unauthenticated' }
    if (!response.ok) return { user: null, headers: response.headers, outcome: 'error' }

    const body = await response.json() as { authenticated?: boolean; user?: AuthUser }
    const user = body.authenticated === true && body.user ? body.user : null
    return { user, headers: response.headers, outcome: user ? 'authenticated' : 'unauthenticated' }
  } catch {
    return { user: null, headers: new Headers(), outcome: 'error' }
  }
}

Containers and non-Worker runtimes — plain HTTP

rubydef self.verify_shared_session(request)
  uri = URI("#{auth_base_url}/session")
  req = Net::HTTP::Get.new(uri)
  req['Accept'] = 'application/json'
  req['Cookie'] = request.env['HTTP_COOKIE'] if request.env['HTTP_COOKIE']

  response = Net::HTTP.start(uri.hostname, uri.port,
    use_ssl: uri.scheme == 'https', open_timeout: 3, read_timeout: 5
  ) { |http| http.request(req) }

  set_cookie_headers = response.get_fields('Set-Cookie') || []
  if response.code.to_i == 401
    return { user: nil, set_cookie_headers: set_cookie_headers, outcome: :unauthenticated }
  end
  unless response.code.to_i == 200
    return { user: nil, set_cookie_headers: set_cookie_headers, outcome: :error }
  end
  body = JSON.parse(response.body)
  user = body['authenticated'] == true ? body['user'] : nil
  { user: user, set_cookie_headers: set_cookie_headers, outcome: user ? :authenticated : :unauthenticated }
rescue StandardError
  { user: nil, set_cookie_headers: [], outcome: :error }
end

Handling the outcome

ts// In your middleware (TypeScript):
const appSession = await parseAppLocalSession(request.headers.get('Cookie'), env.APP_SESSION_SECRET)
if (appSession) {
  // Happy path — no auth call needed
  const user = await resolveUser(db, appSession.id)
  return user ? serveRequest(user) : accessNeededResponse()
}

// App-local miss — fall back to shared /session
const { user, headers: authHeaders, outcome } = await verifySharedSession(env, request.headers)

if (outcome === 'authenticated' && user) {
  const sessionValue = await mintAppLocalSession(user, env.APP_SESSION_SECRET)
  const appUser = await resolveUser(db, user.id)
  if (!appUser) return accessNeededResponse()
  const response = serveRequest(appUser)
  appendSetCookieHeaders(response.headers, authHeaders)
  response.headers.append('Set-Cookie', appLocalCookieHeader(sessionValue))
  return response
}

if (outcome === 'unauthenticated') {
  const response = redirectToLogin(request, env)
  response.headers.append('Set-Cookie', clearAppLocalCookieHeader())
  return response
}

// outcome === 'error' — auth outage is not logout
return authUnavailableResponse() // 503, no cookie clearing, no login redirect

Apps don't need AUTH_DATABASE_URL or BETTER_AUTH_SECRET. Apps only need DATABASE_URL for their own tables, APP_SESSION_SECRET to sign the app-local cookie, and optionally AUTH_BASE_URL to point at a local dev instance.

Resolve the app user

After session validation, look up the app user by auth_user_id. Create the database connection with neon() (HTTP driver) per request — not Pool (WebSocket driver), and not cached at module level:

tsimport { neon } from '@neondatabase/serverless'

// In your request handler or middleware:
const db = neon(env.DATABASE_URL)

export async function resolveUser(db: ReturnType<typeof neon>, authUserId: string) {
  const [user] = await db`
    SELECT id, name, email, role
    FROM users
    WHERE auth_user_id = ${authUserId}
  `
  return user ?? null
}

Why neon() and not Pool? Cloudflare Workers binds I/O objects (including WebSockets) to the request context that created them. Pool uses a WebSocket under the hood — if you cache a Pool at module level and a slow Neon cold start holds that socket across concurrent requests, subsequent requests throw a cross-request I/O error and the Worker crashes. neon() uses plain HTTPS — stateless, no socket, safe to call per request.

No app profile means 403.

First-login linking

Users who existed before the auth migration have a null auth_user_id. On first login, fall back to email:

ts// 1. Try auth_user_id
let user = await resolveUser(db, session.user.id)

// 2. If not found, try email match and link
if (!user) {
  const [matched] = await db`
    UPDATE users SET auth_user_id = ${session.user.id}, updated_at = now()
    WHERE email = ${session.user.email} AND auth_user_id IS NULL
    RETURNING *
  `
  user = matched ?? null
}

One-time per user. After linking, all subsequent logins hit auth_user_id directly.

Login and logout

Login — check for an existing session first. If there is one, skip the auth worker entirely:

ruby# Example (Ruby/Sinatra)
get '/login' do
  return_to = safe_return_to(params[:return_to], '/dashboard')
  redirect return_to if current_user  # Already logged in → skip auth entirely

  auth_base = 'https://auth.example.com'
  app_origin = 'https://app1.example.com'
  @auth_url = "#{auth_base}/login?redirect=#{Rack::Utils.escape("#{app_origin}#{return_to}")}"
  erb :login, layout: false  # Render interstitial that redirects to auth
end

The interstitial page shows a brief loading state ("Signing you in...") and redirects via both JavaScript and a <meta http-equiv="refresh"> fallback:

html<meta http-equiv="refresh" content="2;url=<%= @auth_url %>">
<script>window.location.href = <%= @auth_url.to_json %>;</script>

SSO is automatic. If the user is already logged into any app on the domain, the cookie is present, current_user resolves, and they go straight to the destination. No round-trip, no form.

If the cookie is missing, the interstitial goes to the auth worker. The auth worker also checks for a session first — if it's valid, it redirects back without showing the login form.

https://auth.example.com/login?redirect=https://app1.example.com/dashboard

If there's no valid session anywhere, the user sees the login form. The auth worker validates, sets the cookie on .example.com, and redirects back.

Logout — clear the app-local cookie first, then redirect to the auth worker:

ts// TypeScript
const logoutUrl = `${authBaseUrl}/logout?redirect=${encodeURIComponent('https://app1.example.com/')}`
const response = Response.redirect(logoutUrl, 302)
response.headers.append('Set-Cookie', clearAppLocalCookieHeader())
return response
ruby# Ruby
response.set_cookie(COOKIE_NAME, value: '', max_age: 0, http_only: true, secure: true, same_site: :lax, path: '/')
redirect "#{auth_base_url}/logout?redirect=#{Rack::Utils.escape("https://app1.example.com/")}"

The auth worker invalidates the Better Auth session and clears the central cookie. After central logout, another app tab keeps its app-local session until it expires or the user navigates — the 12-hour TTL is intentionally short to bound this window.

Build the CLI

The CLI manages users across all apps from one place. It runs locally with tsx and reads connection strings from .dev.vars.

json{
  "scripts": {
    "cli": "tsx cli.ts"
  }
}

Commands

CommandWhat it does
invite create <email>Create a 7-day invite, print the accept URL
invite listList all invites with status (pending/accepted/revoked/expired)
invite revoke <id>Revoke a pending invite
user listCross-app view of all users with their role in each app
user create <email> --app1 <role> --app2 <role>Provision into one or both app databases
user set-role <email> --app <name> --role <role>Change a user's role in a specific app
user linkBackfill auth_user_id in all app databases by email match

User list

Queries auth.user and checks each app database for the user's role:

tsasync function userList(env: Record<string, string>, authDb: NeonQueryFunction) {
  const authUsers = await authDb`
    SELECT id, name, email, "createdAt"
    FROM auth."user"
    ORDER BY "createdAt" DESC
  `

  // Check each app DB for roles
  let app1Roles: Record<string, string> = {}
  if (env.APP1_DATABASE_URL) {
    const app1Db = neon(env.APP1_DATABASE_URL)
    const rows = await app1Db`SELECT auth_user_id, role FROM users WHERE auth_user_id IS NOT NULL`
    for (const r of rows) app1Roles[r.auth_user_id as string] = r.role as string
  }

  // Print table with auth user + roles from each app
  for (const u of authUsers) {
    const role1 = app1Roles[u.id as string] ?? '—'
    console.log(`  ${u.name}  ${u.email}  ${role1}`)
  }
}

User provisioning

user create runs three steps:

  1. Checks if the email exists in auth.user
  2. If yes, uses that auth.user.id to insert or update the app's users table with the specified role
  3. If no auth account exists and --invite is passed, creates an invite instead
bash# Provision a user into both apps
npm run cli -- user create jane@example.com --name "Jane Smith" \
  --app1 admin --app2 viewer --invite

# After Jane accepts the invite, link her auth account
npm run cli -- user link

Backfill linking

user link iterates all auth.user rows, matches by email against each app's users table, and sets auth_user_id wherever it's null:

tsfor (const u of authUsers) {
  await appDb`
    UPDATE users SET auth_user_id = ${u.id}, updated_at = now()
    WHERE email = ${u.email} AND auth_user_id IS NULL
  `
}

Run this once after migrating existing users to the new auth system.

Secrets parity

Keep secrets consistent across repos. If one is missing, the deploy script should catch it before anything ships.

SecretAuth WorkerApp 1App 2
AUTH_DATABASE_URLyes
BETTER_AUTH_SECRETyes
DATABASE_URLyesyes
APP_SESSION_SECRETyesyes
AUTH_BASE_URLoptionaloptional

APP_SESSION_SECRET is the HMAC key for signing app-local session cookies. Each app should have its own — don't share it across apps or with the auth worker. Use a separate secret per app so each can be rotated independently.

Each repo should have:

  • A .dev.vars.example showing which variables are needed
  • A deploy.sh that validates all required secrets exist before deploying
  • A setup-secrets.sh that pushes .dev.vars values to Wrangler

Common mistakes

  • Treating auth outage as logout. If /session returns 5xx, times out, or returns bad JSON, that's an infrastructure failure — not a signal that the user is unauthenticated. Clearing cookies and redirecting to login on a transient failure logs out users who are still signed in. Return a 503 / auth-unavailable page instead. Only 401 means the session is actually gone.
  • Building a login form in your app. Redirect to the auth worker. One login page.
  • Caching a Pool (WebSocket driver) at module level. Workers bind I/O objects to the request that created them. A cached Pool works until a slow Neon cold start holds the socket while concurrent requests try to reuse it — then every concurrent request fails with a cross-request I/O error and the isolate crashes. Use neon() (HTTP driver) for ordinary queries. It's stateless, requires no cleanup, and is safe to instantiate per request.
  • Calling /session from a same-zone Worker without a Service Binding. fetch('https://auth.example.com/session') from a Worker on the same zone returns 522. Add a Service Binding and use env.AUTH_SERVICE.fetch().
  • Dropping the Cookie header. The whole flow depends on forwarding it verbatim.
  • Not propagating Set-Cookie from the /session response. Better Auth rotates the token sometimes. Drop those headers and the browser holds a stale token — users get logged out silently.
  • Assuming auth_user_id is always set. Pre-migration users won't have it. Fall back to email on first login and persist the link.
  • Disabling submit buttons with JavaScript. Some browsers cancel the submission when the submitter is disabled. Use pointer-events: none; opacity: 0.7 in CSS instead.

What's next

  • Password reset. Better Auth has sendResetPassword. Wire it to Resend, SES, or Postmark.
  • Email for invites. The CLI prints the accept URL. Wire it to an email transport (Resend, SES, Postmark) to deliver it directly.
  • Session cleanup. Add a Cron Trigger: DELETE FROM auth.session WHERE "expiresAt" < NOW().