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)
wranglerCLI installed and authenticatednodeandnpm
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 = authon 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.crossSubDomainCookiessets the cookie domain to.example.comso 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.useSecureCookiesadds the__Secure-prefix andSecureflag 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-actionin the CSP. Chrome blocks same-origin form POSTs whenform-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)
| Attribute | Production | Development |
|---|---|---|
| Name | __Secure-better-auth.session_token | better-auth.session_token |
| Domain | .example.com | (host only) |
| HttpOnly | true | true |
| Secure | true | false |
| SameSite | Lax | Lax |
| Expiry | 7 days | 7 days |
Apps forward this cookie verbatim to /session and never parse it directly.
App-local cookie
| Attribute | Value |
|---|---|
| Name | __Host-<app>_app_session |
| Domain | (none — host-only via __Host- prefix) |
| HttpOnly | true |
| Secure | true |
| SameSite | Lax |
| Path | / |
| Expiry | 12 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": "[email protected]", "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 notPool? Cloudflare Workers binds I/O objects (including WebSockets) to the request context that created them.Pooluses 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
| Command | What it does |
|---|---|
invite create <email> | Create a 7-day invite, print the accept URL |
invite list | List all invites with status (pending/accepted/revoked/expired) |
invite revoke <id> | Revoke a pending invite |
user list | Cross-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 link | Backfill 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:
- Checks if the email exists in
auth.user - If yes, uses that
auth.user.idto insert or update the app'suserstable with the specified role - If no auth account exists and
--inviteis passed, creates an invite instead
bash# Provision a user into both apps npm run cli -- user create [email protected] --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.
| Secret | Auth Worker | App 1 | App 2 |
|---|---|---|---|
AUTH_DATABASE_URL | yes | — | — |
BETTER_AUTH_SECRET | yes | — | — |
DATABASE_URL | — | yes | yes |
APP_SESSION_SECRET | — | yes | yes |
AUTH_BASE_URL | — | optional | optional |
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.exampleshowing which variables are needed - A
deploy.shthat validates all required secrets exist before deploying - A
setup-secrets.shthat pushes.dev.varsvalues to Wrangler
Common mistakes
- Treating auth outage as logout. If
/sessionreturns 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. Useneon()(HTTP driver) for ordinary queries. It's stateless, requires no cleanup, and is safe to instantiate per request. - Calling
/sessionfrom 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 useenv.AUTH_SERVICE.fetch(). - Dropping the
Cookieheader. The whole flow depends on forwarding it verbatim. - Not propagating
Set-Cookiefrom the/sessionresponse. Better Auth rotates the token sometimes. Drop those headers and the browser holds a stale token — users get logged out silently. - Assuming
auth_user_idis 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.7in 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().
