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.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "better-auth" with this command: npx skills add 5dlabs/cto/5dlabs-cto-better-auth

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:

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>

  &#x3C;Separator />

  &#x3C;div className="space-y-2">
    &#x3C;Button 
      variant="outline" 
      className="w-full" 
      onClick={() => handleSocialSignIn('github')}
    >
      Continue with GitHub
    &#x3C;/Button>
    &#x3C;Button 
      variant="outline" 
      className="w-full" 
      onClick={() => handleSocialSignIn('google')}
    >
      Continue with Google
    &#x3C;/Button>
  &#x3C;/div>
&#x3C;/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: &#x3C;a href="${url}">Click to sign in&#x3C;/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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

code-review

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

mcp-development

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

test-driven-development

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

github-mcp

No summary provided by upstream source.

Repository SourceNeeds Review