brackt/BETTERAUTH_MIGRATION.md
Chris Parsons ba9bf64e37
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)

Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.

**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field

**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict

better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00

7 KiB
Raw Blame History

BetterAuth Migration Checklist

This document covers the manual steps required to complete the Clerk → BetterAuth migration in both development and production.

What's automated (runs via scripts/migrate.mjs on every deploy, idempotent):

  • Schema migrations (sessions, accounts, verifications tables; nullable clerk_id; email_verified column)
  • Convert teams.owner_id and commissioners.user_id from Clerk IDs → users.id UUIDs
  • Mark all Clerk users as email_verified = true
  • Create accounts records for Google and Discord OAuth users (via Clerk API)

What's manual (one-time steps you do by hand):

  • Export user CSV from Clerk (for password hash migration)
  • Run the password migration script
  • Update OAuth redirect URIs in Google/Discord consoles
  • Set env vars in production

Development

Step 1 — Add environment variables

Add these to your .env file:

BETTER_AUTH_SECRET="<random string — run: openssl rand -base64 32>"
BETTER_AUTH_URL="http://localhost:5173"
GOOGLE_CLIENT_ID="<from Clerk dashboard → Social Connections → Google>"
GOOGLE_CLIENT_SECRET="<from Clerk dashboard → Social Connections → Google>"
DISCORD_CLIENT_ID="<from Clerk dashboard → Social Connections → Discord>"
DISCORD_CLIENT_SECRET="<from Clerk dashboard → Social Connections → Discord>"

# Keep temporarily for the automated OAuth migration (remove after confirmed working)
CLERK_SECRET_KEY="<your existing value>"

Remove once confirmed working: VITE_CLERK_PUBLISHABLE_KEY, CLERK_WEBHOOK_SECRET, DEV_ADMIN_CLERK_ID

Step 2 — Apply the database schema migration

npm run db:migrate

Adds the sessions, accounts, and verifications tables; makes clerk_id nullable; adds email_verified.

Step 3 — Add OAuth redirect URIs for localhost

In Google Cloud Console (APIs & Services → Credentials → your OAuth client):

  • Add http://localhost:5173/api/auth/callback/google to Authorized redirect URIs

In Discord Developer Portal (your app → OAuth2):

  • Add http://localhost:5173/api/auth/callback/discord to Redirects

Step 4 — Run the automated data migration

node scripts/migrate.mjs

This converts owner IDs, creates Google/Discord OAuth accounts, and marks users as email-verified. It reads from the Clerk API using your CLERK_SECRET_KEY.

Step 5 — Migrate email/password users

This requires exporting password hashes from Clerk (they're only in the CSV export, not the API).

  1. Go to Clerk dashboard → Users → Export and download the CSV
  2. Save it as exported_users.csv in the project root (it's gitignored)
  3. Run:
    node scripts/migrate-clerk-passwords.mjs
    
  4. Delete exported_users.csv immediately after — it contains bcrypt password hashes

Step 6 — Set yourself as admin

DEV_ADMIN_CLERK_ID no longer works. Set admin access directly in the database:

UPDATE users SET is_admin = true WHERE email = 'your@email.com';

Step 7 — Verify sign-in works

  • Google / Discord: sign in immediately — OAuth accounts were migrated automatically
  • Email/password: sign in with the same password as before — bcrypt hashes were migrated
  • Check that league ownership and commissioner access still works

After verifying, remove CLERK_SECRET_KEY from .env.


Production

Do Steps 13 before merging to main. The data migration (Step 4) and OAuth account creation run automatically on deploy.

Step 1 — Add production environment variables

Add to your production environment (server docker-compose / secrets manager):

BETTER_AUTH_SECRET="<strong random string — openssl rand -base64 32>"
BETTER_AUTH_URL="https://brackt.com"
GOOGLE_CLIENT_ID="<same as Clerk dashboard>"
GOOGLE_CLIENT_SECRET="<same as Clerk dashboard>"
DISCORD_CLIENT_ID="<same as Clerk dashboard>"
DISCORD_CLIENT_SECRET="<same as Clerk dashboard>"

# Keep temporarily — needed for the automated OAuth migration on first deploy
CLERK_SECRET_KEY="<your existing production value>"

Step 2 — Add OAuth redirect URIs for production

In Google Cloud Console:

  • Add https://brackt.com/api/auth/callback/google to Authorized redirect URIs

In Discord Developer Portal:

  • Add https://brackt.com/api/auth/callback/discord to Redirects

Step 3 — Migrate email/password users (before deploying)

The password migration runs against the production database directly — it does NOT go through Docker.

  1. Go to Clerk dashboard → Users → Export and download the CSV for production users
  2. Save as exported_users.csv in the project root (gitignored)
  3. Run against the production database:
    DATABASE_DIRECT_URL="<prod connection string>" CLERK_SECRET_KEY="<prod key>" node scripts/migrate-clerk-passwords.mjs
    
  4. Delete exported_users.csv immediately

Step 4 — Deploy

Merge to main. The GitHub Actions deploy will:

  1. Build and push the Docker image
  2. SSH to the server and run docker compose up --no-deps migrate
  3. scripts/migrate.mjs runs — applies schema migrations, then (because CLERK_SECRET_KEY is set):
    • Converts teams.owner_id and commissioners.user_id to users.id UUIDs
    • Marks users as email_verified
    • Creates accounts records for Google and Discord OAuth users
  4. The app container starts with the new BetterAuth code

Step 5 — Verify production sign-in

  • Google and Discord users: sign in immediately
  • Email/password users: sign in with the same password as before
  • Spot-check that league ownership and commissioner access are intact

Step 6 — Remove Clerk credentials

Once everything is confirmed, remove from the production environment:

CLERK_SECRET_KEY
VITE_CLERK_PUBLISHABLE_KEY
CLERK_WEBHOOK_SECRET
DEV_ADMIN_CLERK_ID

Delete the Clerk webhook endpoint from the Clerk dashboard (it no longer exists in the codebase).

On the next deploy, scripts/migrate.mjs will log "CLERK_SECRET_KEY not set — skipping Clerk account migration" and skip gracefully.


Notes

Why email/password migration is a separate manual step

Clerk's REST API does not expose password hashes — they're only available in the CSV dashboard export. The automated deploy script (scripts/migrate.mjs) handles OAuth accounts via the API, but password hashes must be exported manually and migrated with scripts/migrate-clerk-passwords.mjs.

The bcrypt hashes from Clerk are directly compatible with BetterAuth — no re-hashing is needed. Users sign in with the same password as before.

What if a user signed up with both Google and email/password?

Both an OAuth account and a credential account will be created for them. BetterAuth handles this correctly — the user can sign in with either method.

Future cleanup

After the migration is stable (a week or two after production deploy), open a follow-up PR to:

  1. Remove the clerk_id column from database/schema.ts and run npm run db:generate
  2. Remove CLERK_SECRET_KEY handling from scripts/migrate.mjs
  3. Delete scripts/migrate-clerk-passwords.mjs