Skip to main content

Better Auth rollout

This runbook configures Better Auth as EXEPERT's identity authority while keeping Supabase project vsfajebxloenaertaten as the database, RLS, Realtime, RPC, and Edge Function data plane. The production origin is https://app.exepert.com; the Google Cloud project is exepert-507611.

The repository implementation does not itself change any live dashboard. Do not enable the new runtime until every preflight and rollback item below is complete.

For local end-to-end authentication, start the Vite app and serverless API together with pnpm dev:auth. The ordinary pnpm dev command intentionally serves only the Vite client and therefore returns 404 for /api/auth/*.

Runtime contract

app_private.principal.id is the stable owner UUID carried in JWT sub. Better Auth's authn.user.id can change during an account merge without changing application ownership. The private schemas are never exposed through the Data API.

1. Preflight and database migration

Confirm that the target URL is exactly https://vsfajebxloenaertaten.supabase.co, take a database backup, and record row counts for auth.users, projects, prompt_versions, arcade_episodes, and chat_saved_prompts. Stop if the target or schema differs from the checked- in migration.

Apply supabase/migrations/20260904121150_better_auth_principal_bridge.sql through the normal reviewed Supabase migration workflow. It:

  • creates private authn and app_private schemas;
  • retains every existing Supabase user UUID as a claimable principal;
  • seeds verified email identities without copying password hashes;
  • replaces the four ownership foreign keys with principal foreign keys;
  • installs locked, single-use guest merge and legacy claim procedures; and
  • pre-registers the exepert-desktop public OAuth client and the https://app.exepert.com/api resource.

Afterward, verify that principal count is at least the preflight auth.users count, all four replacement constraints are validated, the private schemas have no grants for anon or authenticated, and the original application row counts are unchanged. Existing email users establish a Better Auth password through password reset; hashes are intentionally not migrated.

The Vercel database credential must be a server-only PostgreSQL URL whose role is a member of exepert_auth_backend. On PostgreSQL 16 and later, grant the membership with INHERIT TRUE; role-level INHERIT alone is not sufficient when the membership's inheritance option is disabled. Keep the pool small; the implementation uses one connection per function instance and sequences identity lookups to avoid exhausting Supabase's transaction pooler.

2. Supabase ES256 signing key

Generate a P-256 key with supabase gen signing-key --algorithm ES256. Store the resulting private JWK in the secret manager before importing the same JWK as a standby signing key in the target Supabase project.

Record its kid, wait until the public key appears at /auth/v1/.well-known/jwks.json, then rotate it to current. Do not revoke the previous key during the migration window: existing Supabase sessions remain the rollback path. Set the full one-line private JWK only as SUPABASE_JWT_PRIVATE_JWK in Vercel and its exact id as SUPABASE_JWT_KID; neither value belongs in client code.

The bridge issues ES256 tokens with a five-minute expiry, audience authenticated, role authenticated, and the stable principal UUID as sub. The eight migrated Edge Functions explicitly validate either this token or a temporary legacy Supabase session before passing the bearer to PostgREST.

3. Google Cloud OAuth

In GCP project exepert-507611, configure the OAuth branding/consent screen for EXEPERT and create one Web application client.

  • Authorized JavaScript origin: https://app.exepert.com
  • Authorized redirect URI: https://app.exepert.com/api/auth/callback/google
  • Local development origin: http://localhost:9001
  • Local development redirect URI: http://localhost:9001/api/auth/callback/google

The redirect URI must match exactly, including scheme, host, path, and port. Only the standard identity scopes (openid, email, and profile) are needed. Put the client id and secret in Vercel as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET; no Google API key or Supabase anon key is created in GCP.

If the consent screen remains in Testing, add the intended test accounts. Do not request paid Google APIs or billing for this sign-in flow.

4. Email and abuse protection

Verify auth.exepert.com in Resend and publish its required SPF/DKIM records in the authoritative Vercel DNS zone for exepert.com. Store RESEND_API_KEY only in Vercel and set RESEND_FROM to a verified sender, for example EXEPERT <no-reply@auth.exepert.com>.

Create a Cloudflare Turnstile widget for app.exepert.com and the intended local test hosts. Store the site key as TURNSTILE_SITE_KEY and the secret as TURNSTILE_SECRET_KEY; both are consumed by the server configuration endpoint and Better Auth respectively. Set TURNSTILE_ALLOWED_HOSTNAMES to an explicit comma-separated allowlist. The Turnstile secret must never use a VITE_ prefix.

exepert.com currently delegates to ns1.vercel-dns.com and ns2.vercel-dns.com; Cloudflare hosts other EXEPERT zones but is not authoritative for this domain. VPSMalaysia remains the registrar. Do not duplicate records in Cloudflare or the registrar's non-authoritative DNS panels.

5. Vercel variables

Set these server-only production variables on the exepert project:

BETTER_AUTH_URL=https://app.exepert.com
BETTER_AUTH_DATABASE_URL=<private PostgreSQL URL>
BETTER_AUTH_SECRET=<at least 32 random characters>
AUTH_RUNTIME_MODE=legacy
SUPABASE_URL=https://vsfajebxloenaertaten.supabase.co
SUPABASE_PUBLISHABLE_KEY=<project publishable key>
SUPABASE_JWT_PRIVATE_JWK=<one-line private ES256 JWK>
SUPABASE_JWT_KID=<matching imported key id>
GOOGLE_CLIENT_ID=<GCP web client id>
GOOGLE_CLIENT_SECRET=<GCP web client secret>
RESEND_API_KEY=<server-only key>
RESEND_FROM=EXEPERT <no-reply@auth.exepert.com>
TURNSTILE_SITE_KEY=<widget site key>
TURNSTILE_SECRET_KEY=<widget secret>
TURNSTILE_ALLOWED_HOSTNAMES=app.exepert.com
EXEPERT_DESKTOP_CLIENT_ID=exepert-desktop
EXEPERT_AUTH_RESOURCE=https://app.exepert.com/api

Set VITE_AUTH_RUNTIME_MODE=legacy for the first deployment. The server's /api/auth/config value is authoritative; the Vite flag is only a fail-safe when that endpoint is unavailable. Existing client-safe VITE_SUPABASE_URL/VITE_SUPABASE_ANON_KEY values remain required for the Supabase data plane.

6. Guarded activation

  1. Apply and verify the database migration while legacy auth remains active.
  2. Deploy the API, web UI, dual-token Edge Functions, and desktop-compatible OAuth metadata with both runtime flags still set to legacy.
  3. Verify /api/auth/health, the authorization-server metadata, protected- resource metadata, and Supabase JWKS without printing secrets.
  4. Import/rotate the ES256 key and verify a server-minted token against an RLS- protected read plus one authenticated Edge Function.
  5. Change AUTH_RUNTIME_MODE and VITE_AUTH_RUNTIME_MODE to better, deploy, then test Google, verified email, reset, guest creation, guest upgrade, legacy claim, Realtime, and restart persistence.
  6. Release the native app only after its loopback state/issuer checks and Windows Credential Manager persistence pass on a real Windows session.

Watch authentication error rate, bridge issuance, merge failures, and RLS denials during the guarded window. Never log cookies, authorization headers, refresh tokens, database URLs, or JWKs.

Production activation record (2026-09-05)

The guarded browser/API cutover is active on app.exepert.com. Vercel deployment dpl_7KZi1GPpq5Wujd25Ay8Jwca9kSy5 reports better from both /api/auth/health and /api/auth/config. OAuth/OIDC discovery, the standards-compatible well-known aliases, the imported Supabase ES256 key, Turnstile, the verified auth.exepert.com Resend domain, the restricted database role, and all eight dual-token Edge Functions are deployed.

Post-activation probes passed for rejected missing sessions/origins/CAPTCHA, Google authorization initiation and callback selection, anonymous Better Auth creation, principal synchronization, authenticated model discovery through the Supabase bridge, and session persistence across a page reload. The production smoke test intentionally created one anonymous Better Auth account.

Do not mark the rollout or native release fully complete until a person finishes the Google account-consent/guest-upgrade flow and the team verifies email delivery/reset, legacy claim, Realtime, and the native Windows loopback plus Credential Manager flow on real clients.

Rollback

Set both runtime flags back to legacy and redeploy the browser/API. Keep the new tables, principal rows, imported signing key, replacement constraints, and dual verifier intact; they are backward-compatible and preserve claim data. Do not drop schemas, revert ownership foreign keys, delete Better Auth users, or revoke the prior Supabase key during an incident. Native users can sign in again after service recovery; their local draft remains available.