Skip to main content

Better Auth

What if your auth library lived in your database, not someone else's cloud?

Better Auth is a TypeScript-first, self-hosted authentication library. Open source, no SaaS dependency, full control over data and testing. As of 2026, the Better Auth team also maintains Auth.js (formerly NextAuth), signalling ecosystem consolidation.

Why Consider It

Pain with ClerkBetter Auth Answer
Bot detection breaks E2E testsYour database, your rules -- no bot gates
No staging instance, manual config syncSame codebase, different DB connection
100 user cap in devUnlimited -- it's your database
Unit tests need custom mock layerTest against real auth with test DB
External dependency for every CI runRuns locally, no network calls
Per-seat pricing at scaleFree forever (open source)

Architecture

Client (Next.js)
|
v
Better Auth SDK (server-side)
|
v
Your Database (Drizzle / Prisma)
|
v
Sessions, Users, Accounts, Orgs -- all in your schema

Self-hosted. Database-backed sessions (default 7-day expiry, 1-day auto-renewal). Optional JWT mode for horizontal scaling. No external auth service to configure, sync, or pay for.

Key Features

FeatureStatus
Email/passwordBuilt-in with scrypt hashing
OAuth providers50+ built-in (Google, GitHub, Apple, etc.)
MFATOTP and SMS via plugin
OrganizationsMulti-tenant with membership, roles via plugin
RBACFine-grained permissions per org
Rate limitingBuilt-in on all routes, stricter on auth endpoints
CSRF protectionOrigin header validation, configurable trusted origins
WebAuthnPasskey support via plugin
Session managementRevoke per-device, admin force-logout

Next.js Integration

Setup with Drizzle

// lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);
// lib/auth.ts
import { betterAuth } from "@better-auth/next";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import * as authSchema from "@/db/auth-schema"; // CLI-generated

export const auth = betterAuth({
adapter: drizzleAdapter(db, { schema: authSchema }),
// plugins: [organizationsPlugin(), mfaPlugin()]
});

Generate schema and migrations via CLI:

npx @better-auth/cli@latest generate  # generates Drizzle schema + relations
npx drizzle-kit generate # generate SQL migrations
npx drizzle-kit push # apply to database

Middleware

// middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";

export async function middleware(req: NextRequest) {
const session = await auth.api.getSession({ headers: req.headers });

if (!session && req.nextUrl.pathname.startsWith("/app")) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}

export const config = { matcher: ["/app/:path*"] };

Server Components

// lib/auth-helpers.ts
import { auth } from "@/lib/auth";

export async function requireSession() {
const session = await auth.api.getSession();
if (!session) throw new Error("UNAUTHENTICATED");
return session;
}

export async function requireOrgRole(orgSlug: string, role: string) {
const session = await requireSession();
const membership = session.user.orgs?.find((m) => m.slug === orgSlug);
if (!membership || !membership.roles.includes(role)) {
throw new Error("FORBIDDEN");
}
return { session, membership };
}
// app/(app)/[org]/page.tsx
import { requireOrgRole } from '@/lib/auth-helpers';

export default async function OrgPage({ params }: { params: { org: string } }) {
const { session } = await requireOrgRole(params.org, 'admin');
return <div>Welcome {session.user.name}</div>;
}

Testing

The key advantage: no external service dependency. Test against a real auth instance backed by a test database.

Integration Tests

// test/auth-setup.ts
import { betterAuth } from "@better-auth/next";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

const testClient = postgres(process.env.TEST_DATABASE_URL!);
const testDb = drizzle(testClient);

export const testAuth = betterAuth({
adapter: drizzleAdapter(testDb, { schema: authSchema }),
});
beforeAll(async () => {
await migrate(testDb, { migrationsFolder: "./drizzle" });
});

it("creates user and authenticates", async () => {
await testAuth.api.signUp({
email: "test@example.com",
password: "secure-password",
});

const session = await testAuth.api.signIn({
email: "test@example.com",
password: "secure-password",
});

expect(session.user.email).toBe("test@example.com");
});

Unit Tests

For components that consume auth, mock the auth helpers (not the library):

vi.mock("@/lib/auth-helpers", () => ({
requireSession: async () => ({
user: { id: "user_1", email: "test@example.com", orgs: [] },
}),
}));

E2E Tests (Playwright)

No testing tokens, no bot detection. Standard Playwright form interaction:

test("sign in flow", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "test@example.com");
await page.fill('[name="password"]', "secure-password");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/dashboard");
});

Migration from Clerk

Data Mapping

Clerk EntityBetter Auth Equivalent
Usersuser table
External Accountsaccount table (OAuth links)
Organizationsorganization table (plugin)
Org Membershipsmember table with roles
Sessionssession table (DB-backed)

Migration Steps

  1. Export users from Clerk (Admin API or Dashboard)
  2. Write migration script inserting into Better Auth's Drizzle tables
  3. Run against staging DB first, dry-run against production exports
  4. Cut-over: freeze Clerk writes, run migration, switch frontend
  5. Update downstream services that consumed Clerk JWTs

Dual-System Bridge

If you need gradual migration: accept Clerk JWTs temporarily. On first Better Auth login, link or create the user record and migrate incrementally.

Known Limitations

LimitationMitigation
Smaller ecosystem than NextAuthGrowing fast (14K+ GitHub stars), Auth.js team now maintains
No WAF/global throttlingAdd rate limiting at reverse proxy layer
Schema evolves with upgradesLock versions, regenerate schema in staging first
Large org counts bloat sessionStore only active org in session, fetch memberships on demand
Newer than Clerk/Auth0Core features solid, increasing production adoption
No managed dashboardBuild your own admin UI or use DB tools

Comparison

DimensionClerkBetter AuthAuth.js/NextAuth
HostingManaged SaaSSelf-hostedSelf-hosted
DatabaseClerk-managedYour DB (Drizzle/Prisma)Your DB (adapters)
TypeScriptSDK with typesNative, full inferenceDeclaration-based
Multi-tenantBuilt-inPluginCustom implementation
MFABuilt-inPluginCustom implementation
TestingRequires mocks + testing tokensTest DB, no external depsTest DB, no external deps
PricingFree tier, then per-seatFree (open source)Free (open source)
Setup timeMinutesHoursHours
EcosystemLargestGrowingLargest open-source
MaintenanceVendor managesYou manageYou manage

Context

Questions

When does the setup cost of self-hosting pay back in testing speed and operational control?

  • What's the break-even point where Clerk's per-seat pricing exceeds self-hosted infrastructure costs?
  • How much CI time do you lose to Clerk's bot detection vs how much setup time does Better Auth require?
  • If Better Auth now maintains Auth.js, does that make it the default choice for new Next.js projects?
  • What org/role complexity level makes the plugin model insufficient?