Skip to content

Auth migrated Better Auth → Privy (headless social + invite codes)

2026-06-27 — login moves from self-hosted Better Auth email/password to Privy as the auth provider, integrated headless behind our own brat-themed UI. This first cut is social login (Google/Apple) + invite-code-gated registration; embedded wallets + a fiat on-ramp are deferred to a post-Solana, Solana-native phase (building them pre-Solana = building them twice).

The migration is bounded by the auth-abstraction: the user_auth bridge isolates the provider, so users.id, every downstream FK, and the JWKS JWT contract are preserved — game-server / admin-server verification is unchanged.

What changed

  • auth-server is a Privy-session → our-JWT bridgePOST /api/auth/privy verifies a Privy access token (@privy-io/server-auth, iss: privy.io, aud: PRIVY_APP_ID), resolves the user by user_auth.auth_provider_user_id = sub, and mints OUR existing JWKS-signed JWT via Better Auth's server-only signJWT (the same key game-server verifies against; no Better Auth session, and /api/auth/sign-jwt is not a public route, so no arbitrary-mint surface).
  • Invite-code-gated registration — the pre-existing invite_codes table is reused as the registration gate (no migration). First sign-in consumes a code; the consume + user-create share one transaction, so a refused code creates no account. Operators mint via POST /admin/invite-codes (service-key).
  • Public email/password signup disabledPOST /api/auth/sign-up/email403. The Better Auth password-reset flow is retired alongside it.
  • Frontend (poker)PrivyProvider + a brat headless login (our own Google/Apple buttons via useLoginWithOAuth, never Privy's modal) + an invite-code field. The Privy → our-JWT bridge publishes the minted JWT through a GameTokenContext override, so useGameToken() and every WS / hook consumer is unchanged. Admin stays on Better Auth (a separate follow-up).

Verification

The bridge + invite gate are vitest-integration-verified live (T1–T7): no-code / reused / expired all refused with no account created; a returning user resolves to the same users.id; email/password signup disabled; and the load-bearing tripwire — a verified Privy token → a minted JWKS JWT that verifies exactly as game-server does (createRemoteJWKSet + jwtVerify, sub === users.id). The brat-UI visual + the real Google/Apple OAuth round-trip are manual smoke on staging.

See the auth-server guide.