Better Auth
Better Auth (better-auth.com) is the most comprehensive authentication framework for TypeScript. Use it for all authentication needs in Node.js backends and React frontends.
AI Tooling
IMPORTANT: Before implementing Better Auth, consult:
-
AI Documentation: https://better-auth.com/llms.txt
-
MCP Server: Available via https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp
Use Context7 to look up Better Auth patterns:
resolve_library_id({ libraryName: "better-auth typescript" }) get_library_docs({ context7CompatibleLibraryID: "/better-auth/better-auth", topic: "installation setup" })
Context7 Better Auth Topics
get_library_docs({ libraryName: "better-auth", topic: "elysia integration" }) get_library_docs({ libraryName: "better-auth", topic: "next.js integration" }) get_library_docs({ libraryName: "better-auth", topic: "two factor authentication" }) get_library_docs({ libraryName: "better-auth", topic: "organization plugin" }) get_library_docs({ libraryName: "better-auth", topic: "session management" })
Installation
Install Better Auth
bun add better-auth # Backend pnpm add better-auth # Frontend
Environment Variables
.env
BETTER_AUTH_SECRET=your-secret-key-at-least-32-chars # Generate with: openssl rand -base64 32 BETTER_AUTH_URL=http://localhost:3000 # Base URL of your app
Backend Integration (Elysia)
Server Configuration
// lib/auth.ts import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { db } from "@/db" // your Drizzle instance
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", // or "mysql", "sqlite" }), emailAndPassword: { enabled: true, autoSignIn: true, // Auto sign-in after registration }, socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }, }, })
Mount Handler in Elysia
// src/index.ts import { Elysia } from "elysia" import { cors } from "@elysiajs/cors" import { auth } from "./lib/auth"
const app = new Elysia() .use(cors({ origin: process.env.FRONTEND_URL || "http://localhost:3001", methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], credentials: true, allowedHeaders: ["Content-Type", "Authorization"], })) .mount(auth.handler) .listen(3000)
console.log(🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port})
Auth Macro for Protected Routes
import { Elysia } from "elysia" import { auth } from "./lib/auth"
// Auth middleware using Elysia macro const betterAuthPlugin = new Elysia({ name: "better-auth" }) .mount(auth.handler) .macro({ auth: { async resolve({ status, request: { headers } }) { const session = await auth.api.getSession({ headers })
if (!session) return status(401)
return {
user: session.user,
session: session.session,
}
},
},
})
// Use in routes
const app = new Elysia()
.use(betterAuthPlugin)
.get("/api/me", ({ user }) => user, { auth: true })
.get("/api/protected", ({ user, session }) => ({
message: Hello ${user.name}!,
sessionId: session.id,
}), { auth: true })
Effect Integration with Better Auth
import { Effect, Context, Layer, Schema } from "effect" import { auth } from "./lib/auth"
// Auth service definition class AuthService extends Context.Tag("AuthService")< AuthService, { getSession: (headers: Headers) => Effect.Effect<Session | null, never> signIn: (email: string, password: string) => Effect.Effect<Session, AuthError> signOut: (headers: Headers) => Effect.Effect<void, never> }
() {}
// Auth errors class AuthError extends Schema.TaggedError<AuthError>("AuthError")({ message: Schema.String, code: Schema.String, }) {}
// Live implementation const AuthServiceLive = Layer.succeed( AuthService, AuthService.of({ getSession: (headers) => Effect.promise(() => auth.api.getSession({ headers })), signIn: (email, password) => Effect.tryPromise({ try: () => auth.api.signInEmail({ body: { email, password } }), catch: (e) => new AuthError({ message: String(e), code: "SIGN_IN_FAILED" }), }), signOut: (headers) => Effect.promise(() => auth.api.signOut({ headers })).pipe(Effect.asVoid), }) )
Frontend Integration (Next.js)
API Route
// app/api/auth/[...all]/route.ts import { auth } from "@/lib/auth" import { toNextJsHandler } from "better-auth/next-js"
export const { GET, POST } = toNextJsHandler(auth)
Server Configuration
// lib/auth.ts import { betterAuth } from "better-auth" import { nextCookies } from "better-auth/next-js" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { db } from "@/db"
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", }), emailAndPassword: { enabled: true, }, socialProviders: { github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, }, plugins: [nextCookies()], // IMPORTANT: Must be last plugin })
Auth Client
// lib/auth-client.ts import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_API_URL || "", // Same domain = empty string })
// Export individual methods for convenience export const { signIn, signUp, signOut, useSession } = authClient
Sign Up Component
'use client'
import { useState } from 'react' import { useRouter } from 'next/navigation' import { authClient } from '@/lib/auth-client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export function SignUpForm() { const router = useRouter() const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() setLoading(true) setError(null)
const formData = new FormData(e.currentTarget)
const { error } = await authClient.signUp.email({
email: formData.get('email') as string,
password: formData.get('password') as string,
name: formData.get('name') as string,
callbackURL: '/dashboard',
}, {
onSuccess: () => router.push('/dashboard'),
onError: (ctx) => setError(ctx.error.message),
})
setLoading(false)
}
return ( <Card className="w-full max-w-md"> <CardHeader> <CardTitle>Create Account</CardTitle> </CardHeader> <CardContent> <form onSubmit={handleSubmit} className="space-y-4"> <div> <Label htmlFor="name">Name</Label> <Input id="name" name="name" required /> </div> <div> <Label htmlFor="email">Email</Label> <Input id="email" name="email" type="email" required /> </div> <div> <Label htmlFor="password">Password</Label> <Input id="password" name="password" type="password" minLength={8} required /> </div> {error && <p className="text-sm text-destructive">{error}</p>} <Button type="submit" className="w-full" disabled={loading}> {loading ? 'Creating account...' : 'Sign Up'} </Button> </form> </CardContent> </Card> ) }
Sign In Component
'use client'
import { useState } from 'react' import { useRouter } from 'next/navigation' import { authClient } from '@/lib/auth-client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Separator } from '@/components/ui/separator'
export function SignInForm() { const router = useRouter() const [loading, setLoading] = useState(false) const [error, setError] = useState<string | null>(null)
async function handleEmailSignIn(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() setLoading(true) setError(null)
const formData = new FormData(e.currentTarget)
await authClient.signIn.email({
email: formData.get('email') as string,
password: formData.get('password') as string,
callbackURL: '/dashboard',
}, {
onSuccess: () => router.push('/dashboard'),
onError: (ctx) => setError(ctx.error.message),
})
setLoading(false)
}
async function handleSocialSignIn(provider: 'github' | 'google') { await authClient.signIn.social({ provider, callbackURL: '/dashboard', }) }
return ( <div className="space-y-4"> <form onSubmit={handleEmailSignIn} className="space-y-4"> <div> <Label htmlFor="email">Email</Label> <Input id="email" name="email" type="email" required /> </div> <div> <Label htmlFor="password">Password</Label> <Input id="password" name="password" type="password" required /> </div> {error && <p className="text-sm text-destructive">{error}</p>} <Button type="submit" className="w-full" disabled={loading}> {loading ? 'Signing in...' : 'Sign In'} </Button> </form>
<Separator />
<div className="space-y-2">
<Button
variant="outline"
className="w-full"
onClick={() => handleSocialSignIn('github')}
>
Continue with GitHub
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => handleSocialSignIn('google')}
>
Continue with Google
</Button>
</div>
</div>
) }
Session Hook Usage
'use client'
import { authClient } from '@/lib/auth-client' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
export function UserMenu() { const { data: session, isPending } = authClient.useSession()
if (isPending) { return <div className="h-8 w-8 animate-pulse rounded-full bg-muted" /> }
if (!session) { return <Button variant="outline" asChild><a href="/sign-in">Sign In</a></Button> }
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="relative h-8 w-8 rounded-full"> <Avatar className="h-8 w-8"> <AvatarImage src={session.user.image || ''} alt={session.user.name} /> <AvatarFallback>{session.user.name?.[0]?.toUpperCase()}</AvatarFallback> </Avatar> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem className="font-medium">{session.user.name}</DropdownMenuItem> <DropdownMenuItem className="text-muted-foreground">{session.user.email}</DropdownMenuItem> <DropdownMenuItem onClick={() => authClient.signOut()}> Sign Out </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) }
Server-Side Session (RSC)
// app/dashboard/page.tsx import { auth } from '@/lib/auth' import { headers } from 'next/headers' import { redirect } from 'next/navigation'
export default async function DashboardPage() { const session = await auth.api.getSession({ headers: await headers(), })
if (!session) { redirect('/sign-in') }
return ( <div> <h1>Welcome, {session.user.name}!</h1> <p>Email: {session.user.email}</p> </div> ) }
Middleware Protection
// middleware.ts import { NextRequest, NextResponse } from 'next/server' import { getSessionCookie } from 'better-auth/cookies'
const protectedRoutes = ['/dashboard', '/settings', '/profile'] const authRoutes = ['/sign-in', '/sign-up']
export function middleware(request: NextRequest) { const sessionCookie = getSessionCookie(request) const isProtected = protectedRoutes.some(route => request.nextUrl.pathname.startsWith(route) ) const isAuthRoute = authRoutes.some(route => request.nextUrl.pathname.startsWith(route) )
// Redirect to sign-in if accessing protected route without session if (isProtected && !sessionCookie) { return NextResponse.redirect(new URL('/sign-in', request.url)) }
// Redirect to dashboard if accessing auth route with session if (isAuthRoute && sessionCookie) { return NextResponse.redirect(new URL('/dashboard', request.url)) }
return NextResponse.next() }
export const config = { matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*', '/sign-in', '/sign-up'], }
Popular Plugins
Plugin Purpose Install
twoFactor
2FA with TOTP, backup codes Built-in
organization
Multi-tenant, teams, roles Built-in
passkey
WebAuthn/Passkey support Built-in
magicLink
Email magic link auth Built-in
apiKey
API key authentication Built-in
username
Username-based auth Built-in
admin
Admin panel, user management Built-in
Two-Factor Authentication (2FA)
Server
import { betterAuth } from "better-auth" import { twoFactor } from "better-auth/plugins"
export const auth = betterAuth({ plugins: [ twoFactor({ issuer: "MyApp", // Shown in authenticator apps totpOptions: { digits: 6, period: 30, }, }), ], })
Client
import { createAuthClient } from "better-auth/react" import { twoFactorClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({ plugins: [ twoFactorClient({ twoFactorPage: "/two-factor", // Redirect here for 2FA verification }), ], })
// Enable 2FA await authClient.twoFactor.enable({ password: "user-password" })
// Verify TOTP code await authClient.twoFactor.verifyTOTP({ code: "123456", trustDevice: true, // Skip 2FA on this device next time })
// Generate backup codes const { data } = await authClient.twoFactor.generateBackupCodes()
Organization (Multi-Tenant)
Server
import { betterAuth } from "better-auth" import { organization } from "better-auth/plugins"
export const auth = betterAuth({ plugins: [ organization({ allowUserToCreateOrganization: true, organizationLimit: 5, // Max orgs per user membershipLimit: 100, // Max members per org roles: { owner: { permissions: ["*"] }, admin: { permissions: ["invite", "remove", "update"] }, member: { permissions: ["read"] }, }, }), ], })
Client
import { organizationClient } from "better-auth/client/plugins"
const authClient = createAuthClient({ plugins: [organizationClient()], })
// Create organization const { data: org } = await authClient.organization.create({ name: "My Company", slug: "my-company", })
// Invite member await authClient.organization.inviteMember({ organizationId: org.id, email: "user@example.com", role: "member", })
// List user's organizations const { data: orgs } = await authClient.organization.list()
// Switch active organization await authClient.organization.setActive({ organizationId: org.id })
Passkey (WebAuthn)
Server
import { betterAuth } from "better-auth" import { passkey } from "better-auth/plugins"
export const auth = betterAuth({ plugins: [ passkey({ rpID: "myapp.com", // Relying Party ID (your domain) rpName: "My App", origin: "https://myapp.com", }), ], })
Client
import { passkeyClient } from "better-auth/client/plugins"
const authClient = createAuthClient({ plugins: [passkeyClient()], })
// Register passkey await authClient.passkey.addPasskey()
// Sign in with passkey await authClient.signIn.passkey()
// List user's passkeys const { data: passkeys } = await authClient.passkey.listPasskeys()
// Delete passkey await authClient.passkey.deletePasskey({ id: passkeyId })
API Keys (Machine-to-Machine)
Server
import { betterAuth } from "better-auth" import { apiKey } from "better-auth/plugins"
export const auth = betterAuth({ plugins: [ apiKey({ rateLimit: { window: 60, // 60 seconds max: 100, // 100 requests per window }, }), ], })
// Validate API key in your routes app.get("/api/data", async (req) => { const key = req.headers["x-api-key"] const result = await auth.api.verifyApiKey({ key }) if (!result.valid) return { error: "Invalid API key" } // result.userId contains the key owner })
Client
import { apiKeyClient } from "better-auth/client/plugins"
const authClient = createAuthClient({ plugins: [apiKeyClient()], })
// Create API key const { data } = await authClient.apiKey.create({ name: "My Integration", expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days }) // data.key is the API key (only shown once!)
// List API keys const { data: keys } = await authClient.apiKey.list()
// Revoke API key await authClient.apiKey.revoke({ id: keyId })
Magic Link (Passwordless)
Server
import { betterAuth } from "better-auth" import { magicLink } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: "Sign in to MyApp",
html: <a href="${url}">Click to sign in</a>,
})
},
expiresIn: 60 * 10, // 10 minutes
}),
],
})
Client
import { magicLinkClient } from "better-auth/client/plugins"
const authClient = createAuthClient({ plugins: [magicLinkClient()], })
// Send magic link await authClient.signIn.magicLink({ email: "user@example.com", callbackURL: "/dashboard", })
Admin Plugin
Server
import { betterAuth } from "better-auth" import { admin } from "better-auth/plugins"
export const auth = betterAuth({ plugins: [ admin({ adminRoles: ["admin", "superadmin"], }), ], })
Client
import { adminClient } from "better-auth/client/plugins"
const authClient = createAuthClient({ plugins: [adminClient()], })
// List all users (admin only) const { data: users } = await authClient.admin.listUsers({ limit: 50, offset: 0, })
// Ban user await authClient.admin.banUser({ userId: "..." })
// Impersonate user await authClient.admin.impersonateUser({ userId: "..." })
// Stop impersonation await authClient.admin.stopImpersonation()
Combining Multiple Plugins
import { betterAuth } from "better-auth" import { twoFactor, organization, passkey, apiKey, admin } from "better-auth/plugins"
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg" }), emailAndPassword: { enabled: true }, plugins: [ twoFactor({ issuer: "MyApp" }), organization({ allowUserToCreateOrganization: true }), passkey({ rpID: "myapp.com", rpName: "MyApp" }), apiKey(), admin({ adminRoles: ["admin"] }), ], })
Database Migration
After configuring Better Auth (especially with plugins), run migrations:
Generate migration file
npx @better-auth/cli generate
Apply migration directly (Kysely adapter only)
npx @better-auth/cli migrate
MCP Server
Better Auth provides an MCP server for AI-assisted development:
{ "mcpServers": { "better-auth": { "url": "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp" } } }
Or add via CLI:
npx @better-auth/cli mcp --claude-code
Best Practices
-
Always use nextCookies() plugin in Next.js for Server Actions
-
Validate sessions server-side for protected actions (don't trust cookie existence)
-
Use Effect for type-safe auth errors in backend services
-
Store secrets in environment variables, never commit them
-
Run database migrations after adding/changing plugins
-
Use social sign-on for better UX where appropriate
Documentation