Authentication
Authentication
What it does
Email/password (with required email verification) and Google OAuth sign-in
via better-auth, a user.role column for coarse admin gating,
Google-account auto-linking, plus session and account management (active
sessions with revoke, linked-provider list, email-confirmed account
deletion). Organizations/teams are a separate layer on top — see
organizations.md. Site admins additionally get ban/unban
and impersonation, covered in its own section below.
How to configure it
| Var | Purpose |
|---|---|
BETTER_AUTH_SECRET | Signs session tokens. openssl rand -base64 32 |
BETTER_AUTH_URL | Origin better-auth issues callback/redirect URLs against |
NEXT_PUBLIC_APP_URL | Same origin, read by the client (lib/auth-client.ts) |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Optional. Without them, the Google button will error — email/password still works |
Google setup: console.cloud.google.com/apis/credentials,
authorized redirect URI http://localhost:3000/api/auth/callback/google
(swap the origin in production).
How it works
lib/auth.ts— the server-sidebetterAuth()config: Drizzle adapter,emailAndPassword.enabled: true, GooglesocialProviders, anadditionalFields.roleonuser(server-set only —input: false, so users can't self-promote via the API),account.accountLinkingwithtrustedProviders: ["google"](a verified Google email links into an existing email/password account instead of creating a duplicate), and the@better-auth/stripe+nextCookies()plugins (nextCookies()must stay last — it's what lets Server Actions set cookies).lib/auth-client.ts— the browser-side client (createAuthClient) exportingsignIn,signUp,signOut,useSession.app/api/auth/[...all]/route.ts— the catch-all handler better-auth needs; don't add routes under/api/auth/.app/(auth)/login/page.tsx/signup/page.tsx— the forms;components/auth/google-button.tsx— the OAuth trigger.lib/db/schema/auth.ts—user,session,account,verificationtables, all shaped to what better-auth expects (don't rename columns without regenerating via@better-auth/cli).lib/dal.ts—verifySession()is the real authorization check, used in every protected page/action (see architecture.md for the full layering withproxy.ts);requireOrg()does the same for org-scoped pages.scripts/promote-admin.ts— the only wayuser.rolebecomes"admin"; run vianpm run promote-admin <email>.- Email verification —
requireEmailVerification: trueblocks sign-in until the address is verified;emailVerification.sendOnSignIn: truere-sends the link on every blocked login attempt (not automatic — better-auth needs this flag explicitly, it doesn't infer it fromrequireEmailVerification). See email.md for the templates. app/(app)/settings/security/page.tsx— active sessions (auth.api.listSessions, revoke viaauthClient.revokeSession/revokeOtherSessions), linked accounts (listUserAccounts, link/unlink Google — the server refuses to unlink your last sign-in method), and account deletion. Deletion is email-confirmed, not immediate:lib/auth.tssetsuser.deleteUser.sendDeleteAccountVerification, which makes/delete-useralways email a link instead of deleting on the first call (the link hits better-auth's ownGET /api/auth/delete-user/callback, handled by the catch-all route — no page needed for it). ItsafterDeletehook also deletes the user'ssubscriptionrow, since that table'sreferenceIdis a plain column, not a FK, so it isn't cascade-deleted likesession/account/memberare.
Admin superpowers — ban, unban, impersonate
- better-auth's
adminplugin (lib/auth.ts, no options needed — its defaults,adminRoles: ["admin"]anddefaultRole: "user", already match this starter's existinguser.rolescheme). Addsuser.banned/banReason/banExpiresandsession.impersonatedBy(hand-written inlib/db/schema/auth.ts, same@better-auth/cli generatelimitation as every other plugin table here — see organizations.md). A banned user is rejected at sign-in with a configurable message (the plugin's ownsession.create.beforedatabase hook) — nothing app-specific needed for that half. lib/actions/admin-users.ts—banUser/unbanUser/impersonateUser/stopImpersonating, all called from plain<form action={fn.bind(null, userId)}>s inapp/(admin)/admin/users/page.tsx(Impersonate/Ban/Unban per row, disabled for your own row) and the impersonation banner (see below) — no client component needed. Each logs its ownaudit_logsrow with a real actor (requireAdmin()) rather than relying on a plugin hook: theadminplugin has noorganizationHooks-style hook to attach to for these events, so the action is the only place an audit entry can be written with a known actor.- Next 16 gotcha, confirmed against this app's actual behavior, not
assumed from older training data (see
AGENTS.md): a Server Action's response only carries a re-rendered payload for the page that invoked it if the action itself callsrevalidatePath/updateTag/refresh, mutates cookies, or callsredirect()— binding an action to a<form>is not, by itself, enough to auto-refresh the table afterward.banUser/unbanUsercallrevalidatePath("/admin/users")explicitly;impersonateUser/stopImpersonatingdon't need to, since theirredirect()calls already trigger the equivalent. - Impersonation swaps the browser's session cookie to the target
user's (via
nextCookies()— must stay last in the plugins array, see below) and redirects into/dashboard.stopImpersonatingreadssession.impersonatedByoff the current (impersonated) session before calling better-auth's own restore endpoint, which is what actually swaps the cookie back.
- Next 16 gotcha, confirmed against this app's actual behavior, not
assumed from older training data (see
- The impersonation banner —
app/(app)/layout.tsxcheckssession.session.impersonatedByon every request under(app); when set, a persistent amber bar (rendered above even the trial banner, so it can't scroll out of view) names both the account being viewed and the admin who started it, with an "Exit impersonation" button posting tostopImpersonating. - Admin dashboard charts (
app/(admin)/admin/page.tsx) —components/admin/bar-chart.tsxis a small hand-rolled SVG bar chart, no charting library. Two series: subscriptions grouped bystatus, and an approximate "revenue by month" (each subscription's monthly plan price, bucketed by the month its current period started) — a stand-in for real Stripe MRR, since thesubscriptiontable (managed by the Stripe plugin) has noamountcolumn of its own. SwaprevenueByMonth()in that file for a real Stripe Invoice API query if you need precision.
Waitlist mode
- The
waitlist_modefeature flag (lib/flags.ts, default off — toggle at/admin/flags). On:app/(auth)/signup/page.tsx(a Server Component) renderscomponents/auth/waitlist-form.tsxinstead of the real signup form — self-serve account creation is off for everyone without a valid invite link. lib/actions/waitlist.ts'sjoinWaitlist— zod-validated, rate-limited (samelib/rate-limit.tspattern aslib/actions/contact.ts), inserts into thewaitlisttable (onConflictDoNothing— a repeat signup is a silent no-op, not an error), and notifies every admin in-app vianotify().app/(admin)/admin/waitlist/page.tsxlists signups with an Invite button per pending row;/admin/waitlist/exportstreams the same rows as CSV.inviteFromWaitlist(admin-gated) generates a random token (crypto.randomBytes, notcrypto.randomUUID— this one's security-sensitive), stores it on the row, emails a${appUrl}/signup? invite=<token>link (emails/waitlist-invite.tsx), and logs"waitlist.invited".- Invite redemption: the signup page checks
?invite=before checking the flag — a valid, unused token always wins, so an invited visitor can sign up even whilewaitlist_modeis on for everyone else.getInviteEmail()validates the token (must bestatus: "invited"andusedAt: null) and returns the waitlisted email, whichcomponents/auth/signup-form.tsxpre-fills into a disabled email field (so the created account matches the invited address). After a successfulauthClient.signUp.email()call, the client calls themarkInviteUsedServer Action directly (no form needed — Server Actions are callable from client code), settingusedAtso the link can't be replayed; visiting it again falls through to the waitlist form like any other visitor.
How to extend it
- More OAuth providers — add to
socialProvidersinlib/auth.ts, plus a button component mirroringgoogle-button.tsx. - Passkeys / magic links / 2FA — better-auth ships plugins for these;
add to the
pluginsarray inlib/auth.tsand the matching client plugin inlib/auth-client.ts. Two-factor is deliberately not enabled here — add it the same way if you need it. - More roles than user/admin — widen the
rolecolumn's implied enum (it's a plaintextcolumn, no DB-level constraint), passadminRolesto theadmin()plugin inlib/auth.tsto match, and updaterequireAdmin()inlib/dal.tsaccordingly. - A new sensitive admin action needing an audit entry — follow
lib/actions/admin-users.ts's pattern: get the actor fromrequireAdmin()before calling the underlyingauth.api.*method (in case the call itself changes whose session is "current," as impersonation does), thenlogAudit().
How to remove it
Auth is load-bearing for billing, admin, and the DAL pattern — removing it
means also removing lib/dal.ts's session-gated helpers, the (auth) route
group, proxy.ts's redirect logic, and the @better-auth/stripe plugin
(billing depends on better-auth's customer-linking). To remove just the
admin superpowers (keeping plain role-based gating): drop the admin()
plugin from lib/auth.ts, lib/actions/admin-users.ts, the
banned/impersonation columns from lib/db/schema/auth.ts (regenerate a
migration), the impersonation banner from app/(app)/layout.tsx, the
ban/unban/impersonate controls from app/(admin)/admin/users/page.tsx, and
components/admin/bar-chart.tsx + the two chart blocks in
app/(admin)/admin/page.tsx (unrelated to the plugin itself, but added in
the same task). To remove waitlist mode: delete lib/actions/waitlist.ts,
components/auth/waitlist-form.tsx, emails/waitlist-invite.tsx,
app/(admin)/admin/waitlist/, the waitlist table (regenerate a
migration), the waitlist_mode entry in lib/flags.ts's KNOWN_FLAGS, the
"Waitlist" admin sidebar link, and revert app/(auth)/signup/page.tsx to
always render SignupFormWithSuspense. Not recommended to remove auth entirely; if you
truly don't need multi-user auth, this isn't the starter to trim it from.