Better Auth
Email/password + OAuth, email verification required, impersonation, Turnstile.
Better Auth is the auth backbone. Typed, framework-agnostic, open source. Server-driven session cookies with the token opaque on the client.
Required env vars
BETTER_AUTH_SECRET=<openssl rand -base64 32> # min 32 chars, validated
BETTER_AUTH_URL=http://localhost:3000 # devBoth are required — the app won't start otherwise (lib/env.ts enforces this).
Email verification is required
Configured in lib/auth.ts: requireEmailVerification: true. Users cannot sign in until they click the verify link emailed by Plunk.
After verification, auto-sign-in is enabled (user doesn't have to type password again).
Without Plunk configured, the verification link is logged to the server console — copy/paste to test locally.
OAuth providers
Google and GitHub are supported, conditionally enabled on their env vars:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...If the pair for a provider is set, that provider's sign-in button appears on /sign-in and /sign-up.
Google OAuth setup
- Google Cloud Console → Credentials → Create Credentials → OAuth Client ID
- Application type: Web application
- Authorized redirect URIs:
http://localhost:3000/api/auth/callback/google(dev)https://your-domain.com/api/auth/callback/google(prod)
GitHub OAuth setup
- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
- Authorization callback URL:
https://your-domain.com/api/auth/callback/github
Better Auth uses BETTER_AUTH_URL to construct callback URLs — make sure it matches the final production domain.
Session configuration
In lib/auth.ts:
- Expiry: 7 days
- Refresh: every 24 hours (rolling)
- Cookie cache TTL: 5 minutes (to avoid hammering the DB on every request)
Sessions live in the session table (Postgres). The cookie only carries an opaque token; all session state is server-side.
Turnstile captcha
If NEXT_PUBLIC_TURNSTILE_SITE_KEY + TURNSTILE_SECRET_KEY are set, Better Auth's captcha plugin is enabled. Tokens are passed via the x-captcha-response header on sign-up, sign-in, and forgot-password.
Admin role
The user.role column is free text. Launchy checks role === "admin" to unlock /admin. Gate enforcement in lib/admin.ts:
export async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() })
if (!session || session.user.role !== "admin") throw new Error("Unauthorized")
return session
}app/admin/layout.tsx calls this and redirects non-admins to / silently.
Promote a user via the helper script:
bun run promote-admin [email protected]The script sets role = 'admin' and revokes the user's active sessions. After running it, the user must sign out and sign in again — the role is cached in the Better Auth cookie for 5 min.
Alternatives: Drizzle Studio (bun run db:studio → user row → change role to admin), or the /admin/users UI once at least one admin exists.
Ban / unban
user table columns:
bannedbooleanban_reasontext nullableban_expirestimestamp nullable
Banned users cannot sign in. UI at /admin/users supports ban/unban with an optional reason field.
Impersonation
Admins can impersonate any user. The created session's impersonated_by column records the original admin ID.
From /admin/users, the Impersonate button calls authClient.admin.impersonateUser(). A banner with Stop impersonating appears while active.
Password reset
Custom handler in lib/auth.ts calls sendPasswordResetEmail (Plunk) with a tokenized link. Users click → land on /reset-password?token=... → set new password.
Client-side usage
"use client"
import { authClient } from "@/lib/auth-client"
export function SignOutButton() {
return <button onClick={() => authClient.signOut()}>Sign out</button>
}Server-side usage
import { auth } from "@/lib/auth"
import { headers } from "next/headers"
const session = await auth.api.getSession({ headers: await headers() })
if (!session) redirect("/sign-in")Or use the typed helper requireAdmin() from lib/admin.ts.
⚠️ BETTER_AUTH_URL must match the real domain
BETTER_AUTH_URL is used as the base for OAuth callback URLs and cookie Domain attribute. If it doesn't exactly match the URL users hit:
- OAuth returns to the wrong domain → infinite redirect loop
- Session cookies aren't accepted → users are "signed out" immediately after sign-in
Concrete rules:
- Dev:
http://localhost:3000 - Prod:
https://your-domain.com(no trailing slash) - Never set it to a Vercel preview URL like
https://app-abc123.vercel.app— preview deploys break unless you set a separate env var per environment in Vercel's settings
After changing BETTER_AUTH_URL in prod, redeploy — the value is read at build/runtime. Without a redeploy, OAuth keeps using the old URL.
Common issues
OAuth redirect loop — BETTER_AUTH_URL doesn't match the URL the user is visiting. See above.
Session not persisting in prod — Check HTTPS (Better Auth requires secure cookies in production) and any reverse proxy cookie handling.
"Email already in use" — Ask the user to recover via Forgot password or delete the orphan account.