better-auth
Universal TypeScript authentication framework for Node.js backends and React frontends
Installation
Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.
View source ↗GuidesSkill instructions
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
Examples
Read the usage examples in the source repository.
View source ↗Source history
- Indexed on
- 2026-01-20
- Index updated
- 2026-03-03
- Branch
- main
Index and sync dates describe this directory. View the source repository for code history.
GitHub ↗chat Comments (0)
Sign in to join the discussion and leave a comment.
No comments yet. Be the first to share your thoughts!