better-auth
Better Auth is a self-hosted TypeScript authentication and authorization framework. Its server owns users, linked accounts, cookie sessions, email and password login, OAuth providers, schema generation, rate limiting, and framework-neutral Request and Response handlers. Typed clients cover React, Vue, Svelte, Solid, and vanilla code. Plugins add two-factor authentication, passkeys, magic links, organizations, teams, API keys, SSO, SCIM, admin tools, and more. Unlike a hosted identity service, your application runs the code, stores the data, sends the mail, configures providers, and responds to incidents.
The most complete self-hosted TypeScript auth framework for teams that genuinely want to own identity data and operations. Do not choose it merely to avoid a vendor bill; the schema, mail, proxy, runtime, recovery, monitoring, and incident workload is real.
Use it if
- You want authentication data in your own SQL or MongoDB database and are prepared to operate the complete security boundary
- A TypeScript application needs email and password, social login, sessions, and advanced features behind one typed server and client API
- The same auth core must serve Next.js, Nuxt, SvelteKit, Hono, Express, TanStack Start, Cloudflare Workers, or another Fetch-compatible framework
- Organizations, teams, passkeys, 2FA, API keys, SSO, or other plugins would otherwise require several unrelated packages and schemas
- You want a vendor to own abuse detection, account recovery operations, uptime, breach response, and compliance evidence: Better Auth is self-hosted code, so those duties remain with your team
- You only need a signed session cookie for a small internal app: the default server package measures about 173 KB gzipped with 17 bundled dependencies, while iron-session covers the narrower job
- You cannot schedule database migrations with auth feature work: core auth requires user, session, account, and verification data, and plugins add fields or tables that must be generated and applied
- Your deployment target is fixed to CommonJS or assumes every Node database driver works at the edge: the Express guide says CommonJS is unsupported, Cloudflare needs AsyncLocalStorage compatibility flags, and each adapter must match the runtime
- You expect enabling the 2FA plugin to protect every sign-in method automatically: its docs say enforcement defaults to credential endpoints, while OAuth, passkey, magic-link, email-OTP, anonymous, and similar flows need custom gating
Setup reality
The package install is the smallest step. Generate a high-entropy BETTER_AUTH_SECRET of at least 32 characters, set the canonical BETTER_AUTH_URL, and configure trusted origins and proxy behavior for every deployed hostname. Secret rotation can use BETTER_AUTH_SECRETS, but it needs a planned rollout. Most deployments need a database plus the matching driver or adapter. Built-in Kysely paths support SQLite, PostgreSQL, and MySQL; Prisma, Drizzle, MongoDB, and other adapters add their own package, client lifecycle, runtime limits, and migration tooling. Better Auth's CLI can run migrate only for the built-in Kysely adapter. With Prisma or Drizzle, run npx auth@latest generate and then apply the result through that ORM. Prisma 7 custom client output changes the import path, and its adapter does not support CLI-driven migration. Every plugin can change the schema, so regenerate before deploying code that enables it. Mount a GET and POST catch-all handler at /api/auth/* and create the matching framework client. Different client and server origins need the full base URL, CORS, trustedOrigins, and cookie settings. OAuth requires provider applications, exact callback URLs, client secrets, and production redirect registration. Email verification, password reset, magic links, and email OTP require your mail transport, templates, delivery retries, and anti-enumeration choices. The docs advise not awaiting verification or reset email directly because timing can reveal account state; serverless environments need waitUntil or an equivalent so background sending is not terminated. Default production rate limiting is process memory, which does not coordinate across serverless instances or replicas. Put counters in the database or secondary storage and configure a trusted client-IP header or explicit trusted proxies; auth.api server calls bypass the built-in limiter. Sessions default to database-backed cookies and refresh behavior, so replicas, cookie caches, cross-subdomain setups, Safari ITP, secure-cookie flags, reverse proxies, and revocation latency all need tests. Plugins must be registered on the server and, when they extend client methods, on the client too. The package offers no finished sign-in UI. At roughly 173 KB gzipped for the main server entry, use better-auth/minimal with an external database adapter when the documented tradeoff fits. Authentication is security-critical, so pin versions, read changelogs, test migrations on a copy, and exercise login, logout, reset, verification, session revocation, OAuth state, CSRF, and rate limits before each upgrade.
Patterns
Configure PostgreSQL and email-password authconfigure-auth-server
import { Pool } from 'pg'
import { betterAuth } from 'better-auth'
export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
trustedOrigins: ['https://app.example.com'],
})Set BETTER_AUTH_SECRET to at least 32 high-entropy characters and BETTER_AUTH_URL to the canonical public origin. Install pg separately.
Generate and apply the auth schemagenerate-database-schema
npx auth@latest generate --yes
# Review the generated Prisma, Drizzle, or SQL schema.
# Apply it with your ORM migration command.
# Built-in Kysely adapter only:
npx auth@latest migrate --yesThe Better Auth migrate command is only for its built-in Kysely adapter. Prisma, Drizzle, and other adapters must apply generated changes with their own migration tools.
Mount the Next.js App Router endpointmount-nextjs-handler
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { GET, POST } = toNextJsHandler(auth)Keep the catch-all at /api/auth unless basePath and the client baseURL are changed together. Pages Router uses toNodeHandler and must disable body parsing.
Create the React clientcreate-react-auth-client
// lib/auth-client.ts
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
baseURL: 'https://app.example.com',
})baseURL can be omitted for same-origin deployments. Cross-origin clients also need server trustedOrigins, CORS, and cookie settings.
Create an email-password accountsign-up-with-email
const { data, error } = await authClient.signUp.email({
name: 'Ada Lovelace',
email: 'ada@example.com',
password,
callbackURL: '/dashboard',
})
if (error) showError(error.message)The default minimum password length is eight. With the default autoSignIn behavior, existing-email signup can return 422 and reveal registration unless verification or autoSignIn settings enable enumeration protection.
Sign in and detect a second-factor challengesign-in-and-handle-2fa
await authClient.signIn.email(
{ email, password, rememberMe: true },
{
onSuccess(ctx) {
if (ctx.data.twoFactorRedirect) {
showTwoFactor(ctx.data.twoFactorMethods)
} else {
goToDashboard()
}
},
onError(ctx) {
showError(ctx.error.message)
},
},
)A 2FA challenge has no authenticated session yet. Server hooks must null-check newSession, and default 2FA enforcement covers credential sign-ins rather than OAuth or passkeys.
Start a GitHub OAuth flowsign-in-with-oauth
await authClient.signIn.social({
provider: 'github',
callbackURL: '/dashboard',
errorCallbackURL: '/sign-in?oauth=failed',
newUserCallbackURL: '/welcome',
})Configure the GitHub client ID and secret on the server and register the exact production callback URL with GitHub. Validate every post-auth redirect destination.
Validate a session on the serverrequire-server-session
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
const session = await auth.api.getSession({
headers: await headers(),
})
if (!session) redirect('/sign-in')
console.log(session.user.id)A cookie-existence check is only an optimistic redirect hint, not authorization. Validate the session again inside every protected page, route, or action.
Read reactive session state in Reactrender-react-session
import { authClient } from '@/lib/auth-client'
export function AccountMenu() {
const { data: session, isPending, error, refetch } = authClient.useSession()
if (isPending) return <span>Loading...</span>
if (error || !session) return <a href="/sign-in">Sign in</a>
return <button onClick={() => authClient.signOut()}>{session.user.name}</button>
}The React client uses a Nano Stores-backed reactive session. Treat pending, error, and signed-out states separately to avoid flashing protected content.
Send password-reset links and revoke sessionsconfigure-recovery-email
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => {
void enqueueEmail({
to: user.email,
subject: 'Reset your password',
text: `Open this link: ${url}`,
})
},
},
})The docs advise not awaiting mail delivery because response timing can reveal whether an account exists. Use a durable queue or serverless waitUntil so the send is not dropped.
Register TOTP and backup-code supportenable-two-factor-plugin
// server
import { twoFactor } from 'better-auth/plugins'
export const auth = betterAuth({
appName: 'Acme Console',
plugins: [twoFactor()],
})
// client
import { twoFactorClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({
plugins: [twoFactorClient({ twoFactorPage: '/two-factor' })],
})Generate and apply the plugin schema. Enabling returns a TOTP URI and backup codes, but twoFactorEnabled remains false until the user verifies a TOTP code.
Use database-backed limits behind a trusted proxypersist-rate-limits
export const auth = betterAuth({
advanced: {
ipAddress: {
ipAddressHeaders: ['cf-connecting-ip'],
},
},
rateLimit: {
enabled: true,
window: 60,
max: 100,
storage: 'database',
customRules: {
'/sign-in/email': { window: 10, max: 3 },
},
},
})Create the rateLimit table through the proper migration flow. Only trust a header that your locked-down proxy overwrites; auth.api server calls do not pass through client-request rate limiting.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| next-auth | npm | A Next.js application prefers Auth.js conventions, provider breadth, and its established framework-specific community |
| passport | npm | An Express-style server wants authentication strategies and will build its own sessions, user model, and account flows |
| supertokens-node | npm | You want an open-source dedicated auth service with backend SDKs and prebuilt UI recipes rather than an in-process framework |
| iron-session | npm | The requirement is only an encrypted stateless cookie session, with no user database, OAuth, recovery, or organization model |