← Back to dashboard
better-authauthfreshReader view (for NotebookLM)

Better Auth Integration Guide

What is Better Auth?

The real model

Self-hosted TypeScript auth where the trade-off is ownership for ops, and every default is a knob.

lib/auth.ts is server-only — it imports the DB driver and reads the secret, so importing it from a client component drags both into the bundle. nextCookies() must be the LAST plugin in the array or later plugins never get cookies written. Middleware's getSessionCookie() checks presence, not validity, so it is a redirect optimisation and never an authorization boundary — re-check with auth.api.getSession({ headers: await headers() }) in every protected route. session.cookieCache trades correctness for latency: a revoked session stays live on other devices until maxAge expires. For codeAmani, the case is sharpest on the Kenya-targeted builds — a per-MAU bill in USD against KES revenue does not survive the spreadsheet, and the phoneNumber plugin makes SMS OTP a primary credential (validate to 254XXXXXXXXX, wire sendOTP to Africa's Talking) for users who have a reliable phone and no reliable email.

Six Better Auth primitives

One server instance, one mounted route, one typed client — plugins extend all three at once.

Text
██████╗ ███████╗████████╗████████╗███████╗██████╗      █████╗ ██╗   ██╗████████╗██╗  ██╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗    ██╔══██╗██║   ██║╚══██╔══╝██║  ██║
██████╔╝█████╗     ██║      ██║   █████╗  ██████╔╝    ███████║██║   ██║   ██║   ███████║
██╔══██╗██╔══╝     ██║      ██║   ██╔══╝  ██╔══██╗    ██╔══██║██║   ██║   ██║   ██╔══██║
██████╔╝███████╗   ██║      ██║   ███████╗██║  ██║    ██║  ██║╚██████╔╝   ██║   ██║  ██║
╚═════╝ ╚══════╝   ╚═╝      ╚═╝   ╚══════╝╚═╝  ╚═╝    ╚═╝  ╚═╝ ╚═════╝    ╚═╝   ╚═╝  ╚═╝

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 / Auth0Better Auth
User data lives inthe vendor's databaseyour Postgres (user, session, account, verification)
Joining users to app datawebhook-synced mirror tablea plain SQL JOIN
Pricingper monthly-active-useryour database bill
Pre-built UIhosted componentsyou build the forms
Email / SMS deliveryincludedyou wire it (Resend, Africa's Talking)
Ops burdennonemigrations, secret rotation, table security

Official Documentation


Setup

1. Install

Bash
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:

Bash
npx auth@latest secret

CLI package name: the current CLI ships as the bare npm package auth — so it is npx auth .... The older @better-auth/cli name is frozen at 1.4.x; do not use it against a current install.

3. Environment variables

Bash
# .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.

TypeScript
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:

TypeScript
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:

Bash
npx auth migrate

With an ORM adapter (Drizzle, Prisma) it emits schema instead, and you run your ORM's own migration tool afterwards:

Bash
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.password holds the password hash. It exists even with email/password disabled, and is marked non-returned so it never leaves the API.
  • account.accessToken / refreshToken / idToken are likewise non-returned.
  • session.token is unique; session.userId is a cascading FK with an index.
  • A rateLimit table is created only when rateLimit.storage === "database". The default "memory" adds no table.

6. Mount the route handler

app/api/auth/[...all]/route.ts:

TypeScript
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:

TypeScript
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.

TSX
"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:

TypeScript
await authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" });

Reading the session

Server (server component, route handler, server action) — authoritative, hits the database:

TypeScript
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:

TSX
const { data: session, isPending } = authClient.useSession();

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.

TypeScript
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.

TypeScript
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.

TypeScript
// 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
]
TypeScript
// 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: passkey lives in its own package (@better-auth/passkey and @better-auth/passkey/client). The others are in core (better-auth/plugins and better-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:

TypeScript
twoFactorClient({
  onTwoFactorRedirect({ twoFactorMethods }) {
    window.location.href = "/2fa";
  },
})

Phone-number OTP

Phone auth is a first-class plugin — you supply the delivery function:

TypeScript
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.

TypeScript
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:

TypeScript
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:

Bash
npm install @better-auth/stripe
TypeScript
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:

  1. Auth data must join app data. Multi-tenant reporting, per-user analytics, and admin tooling get dramatically simpler when user is a real table sitting next to your domain tables instead of a webhook-synced mirror.
  2. 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.
  3. Phone-first identity. Many East African users have a reliable phone number and no reliable email. The phoneNumber plugin makes SMS OTP a primary credential rather than a bolt-on — pair sendOTP with Africa's Talking and normalise to 254XXXXXXXXX inside phoneNumberValidator, matching the M-Pesa phone-format rule in MPESA_PATTERNS.md.

Security

  • BETTER_AUTH_SECRET is server-side only. It signs cookies and encrypts stored OAuth tokens, so a leak is a full session-forgery primitive. Never NEXT_PUBLIC_, never in a client component. Store it in Hazina; note that rotating it invalidates every live session.
  • Never import lib/auth.ts from client code. It pulls the database driver and the secret into the module graph. Client code imports lib/auth-client.ts only.
  • Middleware is not authorization. getSessionCookie() checks presence, not validity. Every protected server route and action re-checks with auth.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. account holds password hashes and OAuth refresh tokens; treat it like a secrets table.
  • Rate-limit the credential routes. Set storage: "database" plus a tight customRules entry 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 per SECURITY.md.
  • Email and SMS delivery are yours. Verification and reset links only exist if you send them. Wire sendVerificationEmail and sendResetPassword to Resend before enabling requireEmailVerification, 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/passkey should be upgraded together.
  • Re-run npx auth generate after 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.