Better Auth Integration Guide
Technology: better-auth · Category: auth · Last reviewed: 2026-08-23
Source: https://tech-stack.codeamanilabs.org/guide/better-auth
Insight:
Better Auth is the self-hosted counterweight to Clerk — the
user,session,accountandverificationtables live in your Postgres, so auth data joins directly against app data and there is no per-MAU bill. You trade a managed dashboard for full ownership: you run the migrations, you secure the tables, you send the emails and the SMS.
██████╗ ███████╗████████╗████████╗███████╗██████╗ █████╗ ██╗ ██╗████████╗██╗ ██╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ ██╔══██╗██║ ██║╚══██╔══╝██║ ██║
██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ ███████║██║ ██║ ██║ ███████║
██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ ██╔══██║██║ ██║ ██║ ██╔══██║
██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ ██║ ██║╚██████╔╝ ██║ ██║ ██║
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
Better Auth Integration Guide
Focus: framework-agnostic, self-hosted TypeScript auth — email/password, social OAuth, sessions, and a first-party plugin ecosystem (organizations, 2FA, passkeys, phone OTP, Stripe billing) running entirely inside your own Next.js app and your own Postgres.
Overview
Better Auth is a library, not a service. You call betterAuth({...}) in your own code, point it at your own database, and mount a single catch-all route. No external identity provider sits in the request path — signing in writes a row to your session table and sets an HttpOnly cookie signed with your BETTER_AUTH_SECRET.
That inversion is the whole trade-off against Clerk and Auth0:
| Clerk / Auth0 | Better Auth | |
|---|---|---|
| User data lives in | the vendor's database | your Postgres (user, session, account, verification) |
| Joining users to app data | webhook-synced mirror table | a plain SQL JOIN |
| Pricing | per monthly-active-user | your database bill |
| Pre-built UI | hosted components | you build the forms |
| Email / SMS delivery | included | you wire it (Resend, Africa's Talking) |
| Ops burden | none | migrations, secret rotation, table security |
flowchart LR
A["Browser<br/>authClient"] -->|"fetch /api/auth/*"| B["Catch-all route<br/>toNextJsHandler(auth)"]
B --> C["betterAuth() instance<br/>lib/auth.ts"]
C --> D["Adapter<br/>drizzle · prisma · pg"]
D --> E[("Your Postgres<br/>user · session<br/>account · verification")]
C --> F["Plugins<br/>organization · 2FA<br/>phoneNumber · stripe"]
B -.->|"Set-Cookie<br/>HttpOnly · Secure · SameSite=Lax"| A
Official Documentation
| Resource | URL |
|---|---|
| Docs home | https://www.better-auth.com/docs |
| Installation | https://www.better-auth.com/docs/installation |
| Next.js integration | https://www.better-auth.com/docs/integrations/next |
| Database & adapters | https://www.better-auth.com/docs/concepts/database |
| Session management | https://www.better-auth.com/docs/concepts/session-management |
| CLI | https://www.better-auth.com/docs/concepts/cli |
| Source | https://github.com/better-auth/better-auth |
Setup
1. Install
npm install better-auth
2. Generate a secret
BETTER_AUTH_SECRET signs session cookies and encrypts stored OAuth tokens. It must be high-entropy and at least 32 characters. The CLI generates one:
npx auth@latest secret
CLI package name: the current CLI ships as the bare npm package
auth— so it isnpx auth .... The older@better-auth/cliname is frozen at1.4.x; do not use it against a current install.
3. Environment variables
# .env.local — server-side only, never NEXT_PUBLIC_*
BETTER_AUTH_SECRET=<32+ char secret from the CLI>
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=<postgres connection string>
# Only for the social providers you actually enable
GOOGLE_CLIENT_ID=<from Google Cloud console>
GOOGLE_CLIENT_SECRET=<from Google Cloud console>
Better Auth resolves the secret as options.secret → BETTER_AUTH_SECRET → AUTH_SECRET, and throws in production when none is set.
4. Create the auth instance
lib/auth.ts — this module imports your database driver and reads the secret, so it is server-only. Never import it from a client component.
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "@/db";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
emailAndPassword: { enabled: true },
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},
plugins: [nextCookies()], // MUST be the last entry in this array
});
Other adapters take the same shape:
import { prismaAdapter } from "better-auth/adapters/prisma";
// database: prismaAdapter(prisma, { provider: "postgresql" })
import { Pool } from "pg";
// database: new Pool({ connectionString: process.env.DATABASE_URL })
nextCookies() works by post-processing the response to write cookies set during server actions. Any plugin listed after it never gets its cookies written — this is the single most common misconfiguration.
5. Create the database tables
With a direct driver (pg, better-sqlite3, mysql2) Better Auth applies migrations itself:
npx auth migrate
With an ORM adapter (Drizzle, Prisma) it emits schema instead, and you run your ORM's own migration tool afterwards:
npx auth generate --adapter drizzle
That produces the four core tables — user, session, account, verification — plus one table per schema-carrying plugin. Field facts worth knowing:
account.passwordholds the password hash. It exists even with email/password disabled, and is marked non-returned so it never leaves the API.account.accessToken/refreshToken/idTokenare likewise non-returned.session.tokenis unique;session.userIdis a cascading FK with an index.- A
rateLimittable is created only whenrateLimit.storage === "database". The default"memory"adds no table.
6. Mount the route handler
app/api/auth/[...all]/route.ts:
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
7. Create the client
lib/auth-client.ts:
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
});
export const { signIn, signUp, signOut, useSession } = authClient;
The import path is per framework — better-auth/react, /vue, /svelte, /solid, or the vanilla better-auth/client.
Key patterns
Sign in and sign up
Every client method returns { data, error } — it does not throw.
"use client";
import { authClient } from "@/lib/auth-client";
async function onSubmit(email: string, password: string) {
const { data, error } = await authClient.signIn.email({ email, password });
if (error) return showError(error.message);
router.push("/dashboard");
}
Social sign-in redirects the browser:
await authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" });
Reading the session
Server (server component, route handler, server action) — authoritative, hits the database:
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
Client — reactive hook:
const { data: session, isPending } = authClient.useSession();
Middleware — cookie presence only
In Next.js middleware, check only that the session cookie exists. Do not call the database or the API there; middleware runs on every matched request and will block them.
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
const { pathname } = request.nextUrl;
if (sessionCookie && ["/login", "/signup"].includes(pathname)) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
if (!sessionCookie && pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*", "/login", "/signup"] };
This is a redirect optimisation, not an authorization boundary. A cookie can be present and invalid. Re-check with auth.api.getSession() inside every protected route, action, and handler.
Session lifetime and the cookie cache
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days total lifetime
updateAge: 60 * 60 * 24, // slide the expiry at most once a day
freshAge: 60 * 5, // "recently authenticated" window for sensitive ops
cookieCache: { enabled: true, maxAge: 5 * 60 },
}
cookieCache trades correctness for latency: session reads come from a short-lived signed cookie instead of the database. A revoked session stays live on other devices until maxAge expires. Keep it short, and leave it off wherever immediate revocation is a requirement.
Cookie defaults are already production-shaped: HttpOnly, SameSite=Lax, and Secure auto-enabled when the resolved base URL is HTTPS or NODE_ENV is production.
Plugins
Plugins come in matched server + client pairs, and any that carry schema require a re-run of npx auth generate.
// lib/auth.ts (server)
import { organization, twoFactor, magicLink, admin } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
plugins: [
organization(),
twoFactor(),
magicLink({ sendMagicLink: async ({ email, url }) => sendEmail(email, url) }),
admin(),
passkey(),
nextCookies(), // always last
]
// lib/auth-client.ts
import {
organizationClient, twoFactorClient, magicLinkClient, adminClient,
} from "better-auth/client/plugins";
import { passkeyClient } from "@better-auth/passkey/client";
plugins: [organizationClient(), twoFactorClient(), magicLinkClient(), adminClient(), passkeyClient()]
Import-path trap:
passkeylives in its own package (@better-auth/passkeyand@better-auth/passkey/client). The others are in core (better-auth/pluginsandbetter-auth/client/plugins). Mixing these up is the most common copy-paste failure.
twoFactorClient() takes an onTwoFactorRedirect callback that fires when a sign-in needs a second factor:
twoFactorClient({
onTwoFactorRedirect({ twoFactorMethods }) {
window.location.href = "/2fa";
},
})
Phone-number OTP
Phone auth is a first-class plugin — you supply the delivery function:
import { phoneNumber } from "better-auth/plugins";
phoneNumber({
otpLength: 6,
expiresIn: 300,
requireVerification: true,
phoneNumberValidator: (n) => /^254\d{9}$/.test(n),
sendOTP: async ({ phoneNumber, code }) => {
await sendSms(phoneNumber, `Your code is ${code}`);
},
})
Client side: authClient.phoneNumber.sendOtp({ phoneNumber }), then authClient.phoneNumber.verify({ phoneNumber, code }).
Rate limiting
Rate limiting runs before any plugin hook or route handler and short-circuits with a 429.
rateLimit: {
enabled: true,
window: 10,
max: 100,
customRules: { "/sign-in/email": { window: 60, max: 5 } },
storage: "database",
}
On Vercel, "memory" gives effectively no protection — each function instance keeps its own counter. Use "database", or a secondaryStorage backed by Redis/Upstash.
Database hooks
Lifecycle side-effects, with the ability to abort:
databaseHooks: {
user: {
create: {
after: async (user) => { await provisionWorkspace(user.id); },
},
delete: {
before: async (user) => !user.email.endsWith("@codeamani.com"),
},
},
}
Returning false from a before hook aborts the operation.
Stripe billing
@better-auth/stripe binds Stripe customers to Better Auth users, and optionally to organizations:
npm install @better-auth/stripe
import { stripe } from "@better-auth/stripe";
plugins: [
organization(),
stripe({
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [{ name: "pro", priceId: process.env.STRIPE_PRO_PRICE_ID as string }],
},
organization: { enabled: true }, // bill the org, not the individual
onEvent: async (event) => {
switch (event.type) {
case "invoice.paid":
break;
}
},
}),
]
codeAmani notes
When to reach for Better Auth over Clerk
Clerk stays the default for codeAmani projects — hosted UI and zero ops win for most US-first SaaS. Reach for Better Auth when one of these holds:
- Auth data must join app data. Multi-tenant reporting, per-user analytics, and admin tooling get dramatically simpler when
useris a real table sitting next to your domain tables instead of a webhook-synced mirror. - Per-MAU pricing breaks the model. The Kenya-targeted builds (
duka-order-bot,boda-dispatch,sacco-chama-assistant) expect large low-ARPU user counts. A per-MAU bill in USD against KES revenue does not survive contact with the spreadsheet; a Neon or Supabase row does. - Phone-first identity. Many East African users have a reliable phone number and no reliable email. The
phoneNumberplugin makes SMS OTP a primary credential rather than a bolt-on — pairsendOTPwith Africa's Talking and normalise to254XXXXXXXXXinsidephoneNumberValidator, matching the M-Pesa phone-format rule inMPESA_PATTERNS.md.
Security
BETTER_AUTH_SECRETis server-side only. It signs cookies and encrypts stored OAuth tokens, so a leak is a full session-forgery primitive. NeverNEXT_PUBLIC_, never in a client component. Store it in Hazina; note that rotating it invalidates every live session.- Never import
lib/auth.tsfrom client code. It pulls the database driver and the secret into the module graph. Client code importslib/auth-client.tsonly. - Middleware is not authorization.
getSessionCookie()checks presence, not validity. Every protected server route and action re-checks withauth.api.getSession(). - Secure the auth tables yourself. Better Auth connects over a direct Postgres connection and does not go through PostgREST. On Supabase that means its tables are not covered by anything you configured for the API — either keep them out of the exposed schema, or write RLS policies for them.
accountholds password hashes and OAuth refresh tokens; treat it like a secrets table. - Rate-limit the credential routes. Set
storage: "database"plus a tightcustomRulesentry on/sign-in/email. The default in-memory limiter is per-instance and does nothing on serverless. - Webhook signature verification still applies, but to plugins. Better Auth is self-hosted and has no inbound provider webhook of its own to verify (unlike Clerk's Svix events). The webhook surface arrives with
@better-auth/stripe, which receives real Stripe events — verify the Stripe signing secret against the raw request body perSECURITY.md. - Email and SMS delivery are yours. Verification and reset links only exist if you send them. Wire
sendVerificationEmailandsendResetPasswordto Resend before enablingrequireEmailVerification, or users get locked out silently.
Ops
- Pin the version. Better Auth moves fast, and the plugin packages track the same release train as core —
better-auth,@better-auth/stripe, and@better-auth/passkeyshould be upgraded together. - Re-run
npx auth generateafter every plugin addition, then commit the generated migration. A missing plugin table fails at runtime, not at build. - Use a pooled connection string on Vercel; the adapter opens connections per invocation.
Official docs: