Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

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

VarPurpose
BETTER_AUTH_SECRETSigns session tokens. openssl rand -base64 32
BETTER_AUTH_URLOrigin better-auth issues callback/redirect URLs against
NEXT_PUBLIC_APP_URLSame origin, read by the client (lib/auth-client.ts)
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETOptional. 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-side betterAuth() config: Drizzle adapter, emailAndPassword.enabled: true, Google socialProviders, an additionalFields.role on user (server-set only — input: false, so users can't self-promote via the API), account.accountLinking with trustedProviders: ["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) exporting signIn, 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, verification tables, 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 with proxy.ts); requireOrg() does the same for org-scoped pages.
  • scripts/promote-admin.ts — the only way user.role becomes "admin"; run via npm run promote-admin <email>.
  • Email verification — requireEmailVerification: true blocks sign-in until the address is verified; emailVerification.sendOnSignIn: true re-sends the link on every blocked login attempt (not automatic — better-auth needs this flag explicitly, it doesn't infer it from requireEmailVerification). See email.md for the templates.
  • app/(app)/settings/security/page.tsx — active sessions (auth.api.listSessions, revoke via authClient.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.ts sets user.deleteUser.sendDeleteAccountVerification, which makes /delete-user always email a link instead of deleting on the first call (the link hits better-auth's own GET /api/auth/delete-user/callback, handled by the catch-all route — no page needed for it). Its afterDelete hook also deletes the user's subscription row, since that table's referenceId is a plain column, not a FK, so it isn't cascade-deleted like session/account/member are.

Admin superpowers — ban, unban, impersonate

  • better-auth's admin plugin (lib/auth.ts, no options needed — its defaults, adminRoles: ["admin"] and defaultRole: "user", already match this starter's existing user.role scheme). Adds user.banned/ banReason/banExpires and session.impersonatedBy (hand-written in lib/db/schema/auth.ts, same @better-auth/cli generate limitation as every other plugin table here — see organizations.md). A banned user is rejected at sign-in with a configurable message (the plugin's own session.create.before database 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 in app/(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 own audit_logs row with a real actor (requireAdmin()) rather than relying on a plugin hook: the admin plugin has no organizationHooks-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 calls revalidatePath/updateTag/refresh, mutates cookies, or calls redirect() — binding an action to a <form> is not, by itself, enough to auto-refresh the table afterward. banUser/ unbanUser call revalidatePath("/admin/users") explicitly; impersonateUser/stopImpersonating don't need to, since their redirect() 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. stopImpersonating reads session.impersonatedBy off the current (impersonated) session before calling better-auth's own restore endpoint, which is what actually swaps the cookie back.
  • The impersonation banner — app/(app)/layout.tsx checks session.session.impersonatedBy on 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 to stopImpersonating.
  • Admin dashboard charts (app/(admin)/admin/page.tsx) — components/admin/bar-chart.tsx is a small hand-rolled SVG bar chart, no charting library. Two series: subscriptions grouped by status, 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 the subscription table (managed by the Stripe plugin) has no amount column of its own. Swap revenueByMonth() in that file for a real Stripe Invoice API query if you need precision.

Waitlist mode

  • The waitlist_mode feature flag (lib/flags.ts, default off — toggle at /admin/flags). On: app/(auth)/signup/page.tsx (a Server Component) renders components/auth/waitlist-form.tsx instead of the real signup form — self-serve account creation is off for everyone without a valid invite link.
  • lib/actions/waitlist.ts's joinWaitlist — zod-validated, rate-limited (same lib/rate-limit.ts pattern as lib/actions/contact.ts), inserts into the waitlist table (onConflictDoNothing — a repeat signup is a silent no-op, not an error), and notifies every admin in-app via notify().
  • app/(admin)/admin/waitlist/page.tsx lists signups with an Invite button per pending row; /admin/waitlist/export streams the same rows as CSV. inviteFromWaitlist (admin-gated) generates a random token (crypto.randomBytes, not crypto.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 while waitlist_mode is on for everyone else. getInviteEmail() validates the token (must be status: "invited" and usedAt: null) and returns the waitlisted email, which components/auth/signup-form.tsx pre-fills into a disabled email field (so the created account matches the invited address). After a successful authClient.signUp.email() call, the client calls the markInviteUsed Server Action directly (no form needed — Server Actions are callable from client code), setting usedAt so 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 socialProviders in lib/auth.ts, plus a button component mirroring google-button.tsx.
  • Passkeys / magic links / 2FA — better-auth ships plugins for these; add to the plugins array in lib/auth.ts and the matching client plugin in lib/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 role column's implied enum (it's a plain text column, no DB-level constraint), pass adminRoles to the admin() plugin in lib/auth.ts to match, and update requireAdmin() in lib/dal.ts accordingly.
  • A new sensitive admin action needing an audit entry — follow lib/actions/admin-users.ts's pattern: get the actor from requireAdmin() before calling the underlying auth.api.* method (in case the call itself changes whose session is "current," as impersonation does), then logAudit().

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.