Building Production-Ready Auth with Better Auth
Authentication is one of those things where "good enough" isn't. A half-built auth system is worse than no auth system - it gives you false confidence while leaving real gaps.
When I built BuildElevate - a production-grade monorepo starter and CLI tool - I needed auth that covered the full surface area: email/password with verification, Google OAuth with account linking, TOTP-based 2FA, rate-limited transactional emails, and password reset flows. All of it type-safe, all of it working out of the box for anyone who scaffolds a project with the CLI.
I chose Better Auth. This post is a complete walkthrough of how I set it up, why I made the decisions I did, and the details that most auth tutorials skip.
Why Better Auth
I evaluated three options seriously:
Auth.js (NextAuth) - the most popular choice. The config is verbose, the database adapter story is messy, and the TypeScript types have historically been weak. It's improving, but it felt like fighting the library on every edge case.
Clerk - excellent developer experience, but it's a paid third-party SaaS. I didn't want to bake an external dependency with a pricing model into a starter template that other people would use. The moment you hit Clerk's free tier limits, you're paying - or migrating.
Better Auth - self-hosted, clean TypeScript APIs, first-class Prisma support, built-in plugins for 2FA and OAuth, and a session model I could actually read and understand. It owns your data, it runs on your infrastructure, and the configuration is explicit without being verbose.
For a starter template, self-hosted auth was the right call. Better Auth won.
Project Structure
Auth lives in a shared package inside the monorepo, used by both the Next.js frontend and the Express API:
packages/
auth/
src/
server.ts ← Better Auth config (server-side)
client.ts ← Auth client (browser/React)
handler.ts ← Next.js route handler adapter
keys.ts ← Type-safe env varsKeeping auth in a shared package means the server config and client stay in sync - one source of truth for the entire monorepo.
The Server Config
The heart of the setup is the auth object in server.ts. Let's walk through it section by section.
Database adapter
import { prisma } from '@workspace/db';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
// ...
});Better Auth handles its own schema - sessions, accounts, verifications - through the Prisma adapter. You don't write the auth tables yourself; you run npx better-auth generate and it outputs the Prisma schema additions. Clean separation between your app's data model and the auth layer.
Email and password with required verification
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
const { data, success, error } = resetPasswordSchema.safeParse({
name: user.name,
resetUrl: url,
});
if (error || !success) {
throw new Error('Failed to send password reset email');
}
await sendAuthEmail({
emailType: 'reset-password',
limiter: resetPasswordRateLimiter,
user,
data,
});
},
},requireEmailVerification: true means unverified users cannot sign in. This is the right default for any production app - it prevents fake account creation and ensures you have a valid email for every user.
Notice the Zod validation before sending the email (resetPasswordSchema.safeParse). This is a small but important pattern: validate the data shape before passing it to the email renderer. If Better Auth ever sends unexpected data, the error surfaces immediately rather than silently sending a broken email.
Email verification flow
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
const { data, success, error } = verifyEmailSchema.safeParse({
email: user.email,
name: user.name,
verificationUrl: url,
});
if (error || !success) {
throw new Error('Failed to send verification email');
}
await sendAuthEmail({
emailType: 'verify-email',
limiter: verifyEmailRateLimiter,
user,
data,
});
},
async afterEmailVerification(user, request) {
const origin = request ? new URL(request.url).origin : '';
await sendAuthEmail({
emailType: 'welcome',
limiter: welcomeEmailRateLimiter,
user,
data: {
name: user.name,
getStartedUrl: origin,
},
});
},
},Two things worth noting here:
autoSignInAfterVerification: true - after a user clicks the verification link, they're signed in automatically. This is the right UX. Making someone verify their email and then also go back and sign in is unnecessary friction.
afterEmailVerification welcome email - this hook fires once, right after the email is verified. It's the correct place to send a welcome email. If you send the welcome email on sign-up, it goes to users who never verify - which is wasted sends and a signal to spam filters. Send it after verification, when you know the user is real.
Rate-limited transactional emails
Every email send goes through a rate limiter:
await sendAuthEmail({
emailType: 'reset-password',
limiter: resetPasswordRateLimiter,
user,
data,
});The sendAuthEmail wrapper checks the limiter before calling the email provider (Resend + React Email in this case). Without this, a single user or a bot can trigger hundreds of password reset emails in seconds, burning through your email provider quota and potentially getting your domain flagged for spam.
Each email type has its own limiter with different thresholds:
export const resetPasswordRateLimiter = new Ratelimit({ limiter: slidingWindow(3, '1 h') });
export const verifyEmailRateLimiter = new Ratelimit({ limiter: slidingWindow(3, '1 h') });
export const changeEmailRateLimiter = new Ratelimit({ limiter: slidingWindow(3, '1 h') });
export const welcomeEmailRateLimiter = new Ratelimit({ limiter: slidingWindow(1, '24 h') });The welcome email gets a 24-hour window with a limit of 1 - it should only ever be sent once per user. The others allow 3 per hour, which is generous enough for a legitimate user but tight enough to block abuse.
Email change
user: {
changeEmail: {
enabled: true,
sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {
const { data, success, error } = changeEmailSchema.safeParse({
currentEmail: user.email,
newEmail,
name: user.name,
verificationUrl: url,
});
if (error || !success) {
throw new Error('Failed to send email change confirmation');
}
await sendAuthEmail({
emailType: 'change-email',
limiter: changeEmailRateLimiter,
user,
data,
});
},
},
deleteUser: {
enabled: true,
},
},Email changes require confirmation to the new email before the change takes effect. This prevents account takeover via email change - if an attacker gets into an account, they can't silently swap the email and lock out the real owner. The confirmation link goes to the new address, which only the real owner of that address can click.
deleteUser: { enabled: true } is also worth calling out. Most auth libraries don't expose user deletion by default. Enabling it explicitly is the right call - GDPR requires the ability to delete accounts, and users expect it.
Google OAuth with account linking
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ['google'],
updateUserInfoOnLink: true,
},
},Account linking is the feature that solves a problem most apps handle badly. Imagine a user signs up with user@gmail.com via email/password. Later they try to sign in with Google using the same address. Without account linking, the app creates a second account. With account linking, the Google account is linked to the existing account automatically.
trustedProviders: ['google'] means the link happens without requiring additional confirmation. This is safe for Google specifically because Google verifies email ownership before issuing tokens. You wouldn't set trustedProviders for a provider that doesn't verify emails.
updateUserInfoOnLink: true means if the user's name in Google is more up-to-date than what's stored in your database, it gets synced on link. Small detail, but it keeps user profiles fresh.
TOTP 2FA
plugins: [
twoFactor({
issuer: 'BuildElevate',
}),
],One line to add TOTP-based two-factor authentication. Under the hood, Better Auth's twoFactor plugin:
- Generates a TOTP secret per user
- Provides QR code setup flow
- Validates 6-digit codes on sign-in
- Handles backup codes for account recovery
The issuer string is what shows up in the authenticator app (Google Authenticator, Authy, etc.) next to the 6-digit code. Set it to your app name.
The Next.js Route Handler
Better Auth needs a single catch-all API route to handle all auth requests:
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from './server';
export const authHandlers = toNextJsHandler(auth);import { authHandlers } from '@workspace/auth/handler';
export const { GET, POST } = authHandlers;toNextJsHandler wraps the Better Auth handler in a Next.js-compatible format. Every auth operation — sign-in, sign-up, OAuth redirects, email verification callbacks, 2FA - flows through this single route. You don't write individual route handlers for each action.
The Client
The client-side config mirrors the server - same plugins, pointing at the same base URL:
import { twoFactorClient } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { keys } from './keys';
export const authClient = createAuthClient({
baseURL: keys().NEXT_PUBLIC_BASE_URL,
plugins: [twoFactorClient()],
});Better Auth exports everything you need directly off the client:
export const {
signIn,
signOut,
signUp,
useSession,
getSession,
revokeSession,
sendVerificationEmail,
listAccounts,
linkSocial,
unlinkAccount,
twoFactor,
changePassword,
changeEmail,
updateUser,
deleteUser,
requestPasswordReset,
resetPassword,
$Infer,
} = authClient;
export type Session = typeof authClient.$Infer.Session;The $Infer.Session type is one of Better Auth's best features. The session type is inferred directly from your server config - so if you add a plugin that attaches extra fields to the session, the client type updates automatically. No manual type maintenance.
Using auth in a React component looks like this:
import { useSession, signOut } from '@workspace/auth/client';
export function UserMenu() {
const { data: session, isPending } = useSession();
if (isPending) return <Spinner />;
if (!session) return <SignInButton />;
return (
<div>
<p>Welcome, {session.user.name}</p>
<button onClick={() => signOut()}>Sign out</button>
</div>
);
}And in a Server Component or API route:
import { auth } from '@workspace/auth/server';
import { fromNodeHeaders } from '@workspace/auth';
// In a Next.js Server Component
const session = await auth.api.getSession({
headers: await headers(),
});
// In an Express route
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});fromNodeHeaders converts Node.js-style headers (an object) to the Web API Headers format that Better Auth expects. This is what makes the same auth package work across both a Next.js app and an Express API in the same monorepo.
The Full Auth Surface Area
Here's everything the setup covers out of the box:
| Feature | How |
|---|---|
| Email/password sign-up | signUp.email() |
| Email verification | Automatic on sign-up, required before sign-in |
| Password reset | requestPasswordReset() → email → resetPassword() |
| Google OAuth | signIn.social({ provider: 'google' }) |
| Account linking | Automatic for trusted providers |
| TOTP 2FA setup | twoFactor.enable() → QR code → verify |
| TOTP 2FA sign-in | twoFactor.verifyTotp() |
| Email change | changeEmail() → confirmation to new address |
| Password change | changePassword() |
| Session revocation | revokeSession() |
| Account deletion | deleteUser() |
| Rate-limited emails | Per email type, via Upstash Redis |
| Welcome email | After first email verification |
What I'd Warn You About
The schema migration step. Better Auth manages its own tables but generates the Prisma schema additions for you. Run npx better-auth generate before your first prisma migrate dev. If you add plugins later (like twoFactor), run it again - new plugins add new tables.
Environment variables in the monorepo. The server config uses process.env.GOOGLE_CLIENT_ID directly, but the client needs NEXT_PUBLIC_BASE_URL. Make sure your monorepo's env setup (.env at the root, or per-app .env.local) exposes the right variables to the right packages. Using a keys() function with Zod validation - as I do in the client - catches missing env vars at startup rather than at runtime.
The trustedProviders decision. Only mark a provider as trusted if it verifies email ownership before issuing tokens. Google does. GitHub does. A hypothetical custom OAuth provider might not. When in doubt, leave it out of trustedProviders and require explicit user confirmation for the link.
2FA backup codes. The twoFactor plugin generates backup codes. Build the UI to show them during setup and make users acknowledge they've saved them. A user who loses their phone and has no backup codes is locked out permanently.
Why This Beats Rolling Your Own
Auth is one of those things that seems simple until it isn't. Rolling your own means implementing:
- Secure password hashing (bcrypt/argon2, with correct cost factors)
- Session token generation (cryptographically random, correct length)
- CSRF protection
- OAuth 2.0 state parameter validation
- TOTP secret generation and time-window validation
- Secure email verification token expiry
Each of those has subtle failure modes that are invisible until someone exploits them. Better Auth handles all of it correctly by default. Your job is configuration, not cryptography.
The code in this post is the actual auth setup shipping inside BuildElevate - if you scaffold a project with the CLI, this is what you get out of the box.
I write about full-stack development, systems design, and building in public as a CS student. Find me on Twitter/X.