
Better Auth
- 1.5k installs
- 311 repo stars
- Updated June 22, 2026
- giuseppe-trisciuoglio/developer-kit
better-auth is an agent skill for integrate better auth with nestjs backend and next.js app router using drizzle and postgresql.
About
The better-auth skill is designed for integrate Better Auth with NestJS backend and Next.js App Router using Drizzle and PostgreSQL. Better Auth Integration Guide Overview Better Auth is a type-safe authentication framework for TypeScript supporting multiple providers, 2FA, SSO, organizations, and passkeys. This skill covers integration patterns for NestJS backend with Drizzle ORM + PostgreSQL and Next.js App Router frontend. Invoke when the user sets up Better Auth with NestJS, Next.js, Drizzle, or PostgreSQL.
- Setting up Better Auth with NestJS backend.
- Integrating Next.js App Router frontend.
- Configuring Drizzle ORM schema with PostgreSQL.
- Implementing social login (GitHub, Google, Facebook, Microsoft).
- Adding MFA/2FA with TOTP, passkey passwordless auth, or magic links.
Better Auth by the numbers
- 1,482 all-time installs (skills.sh)
- +58 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #319 of 4,386 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
better-auth capabilities & compatibility
- Capabilities
- setting up better auth with nestjs backend · integrating next.js app router frontend · configuring drizzle orm schema with postgresql · implementing social login (github, google, faceb
What better-auth says it does
Provides Better Auth integration patterns for NestJS backend and Next.js frontend with Drizzle ORM and PostgreSQL. Use when setting up Better Auth with NestJS backend, integrating
Provides Better Auth integration patterns for NestJS backend and Next.js frontend with Drizzle ORM and PostgreSQL. Use when setting up Better Auth with NestJS b
npx skills add https://github.com/giuseppe-trisciuoglio/developer-kit --skill better-authAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.5k |
|---|---|
| repo stars | ★ 311 |
| Security audit | 3 / 3 scanners passed |
| Last updated | June 22, 2026 |
| Repository | giuseppe-trisciuoglio/developer-kit ↗ |
How do I integrate better auth with nestjs backend and next.js app router using drizzle and postgresql?
Integrate Better Auth with NestJS backend and Next.js App Router using Drizzle and PostgreSQL.
Who is it for?
Full-stack teams wiring Better Auth across NestJS API and Next.js frontend.
Skip if: Skip for Auth0 or Clerk-only setups without Better Auth adoption.
When should I use this skill?
User sets up Better Auth with NestJS, Next.js, Drizzle, or PostgreSQL.
What you get
Completed better-auth workflow with documented commands, files, and expected deliverables.
- .env configuration
- OAuth provider wiring
- Session management setup
By the numbers
- Documents 4 OAuth providers: GitHub, Google, Microsoft, Facebook
- BETTER_AUTH_SECRET requires minimum 32 characters
Files
Better Auth Integration Guide
Overview
Better Auth is a type-safe authentication framework for TypeScript supporting multiple providers, 2FA, SSO, organizations, and passkeys. This skill covers integration patterns for NestJS backend with Drizzle ORM + PostgreSQL and Next.js App Router frontend.
When to Use
- Setting up Better Auth with NestJS backend
- Integrating Next.js App Router frontend
- Configuring Drizzle ORM schema with PostgreSQL
- Implementing social login (GitHub, Google, Facebook, Microsoft)
- Adding MFA/2FA with TOTP, passkey passwordless auth, or magic links
- Managing trusted devices and backup codes for account recovery
- Building multi-tenant apps with organizations or SSO
- Creating protected routes with session management
Quick Start
Installation
# Backend (NestJS)
npm install better-auth @auth/drizzle-adapter drizzle-orm pg
npm install -D drizzle-kit
# Frontend (Next.js)
npm install better-auth4-Phase Setup
1. Database: Install Drizzle, configure schema, run migrations 2. Backend: Create Better Auth instance with NestJS module 3. Frontend: Configure auth client, create pages, add middleware 4. Plugins: Add 2FA, passkey, organizations as needed
See references/nestjs-setup.md for complete backend setup, references/plugins.md for plugin configuration.
Instructions
Phase 1: Database Setup
1. Install dependencies
npm install drizzle-orm pg @auth/drizzle-adapter better-auth
npm install -D drizzle-kit2. Create Drizzle config (drizzle.config.ts)
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/auth/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! },
});3. Generate and run migrations
npx drizzle-kit generate
npx drizzle-kit migrateCheckpoint: Verify tables created: psql $DATABASE_URL -c "\dt" should show user, account, session, verification_token tables.
Phase 2: Backend Setup (NestJS)
1. Create database module - Set up Drizzle connection service
2. Configure Better Auth instance
// src/auth/auth.instance.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from '@auth/drizzle-adapter';
import * as schema from './schema';
export const auth = betterAuth({
database: drizzleAdapter(schema, { provider: 'postgresql' }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
}
}
});3. Create auth controller
@Controller('auth')
export class AuthController {
@All('*')
async handleAuth(@Req() req: Request, @Res() res: Response) {
return auth.handler(req);
}
}Checkpoint: Test endpoint GET /auth/get-session returns { session: null } when unauthenticated (no error).
Phase 3: Frontend Setup (Next.js)
1. Configure auth client (lib/auth.ts)
import { createAuthClient } from 'better-auth/client';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL!
});2. Add middleware (middleware.ts)
import { auth } from '@/lib/auth';
export default auth((req) => {
if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) {
return Response.redirect(new URL('/sign-in', req.nextUrl.origin));
}
});
export const config = { matcher: ['/dashboard/:path*'] };3. Create sign-in page with form or social buttons
Checkpoint: Navigating to /dashboard when logged out should redirect to /sign-in.
Phase 4: Advanced Features
Add plugins from references/plugins.md:
- 2FA:
twoFactor({ issuer: 'AppName', otpOptions: { sendOTP } }) - Passkey:
passkey({ rpID: 'domain.com', rpName: 'App' }) - Organizations:
organization({ avatar: { enabled: true } }) - Magic Link:
magicLink({ sendMagicLink }) - SSO:
sso({ saml: { enabled: true } })
Checkpoint: After adding plugins, re-run migrations and verify new tables exist.
Examples
Example 1: Server Component with Session
Input: Display user data in a Next.js Server Component.
// app/dashboard/page.tsx
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect('/sign-in');
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
</div>
);
}Output: Renders user info for authenticated users; redirects unauthenticated to sign-in.
Example 2: 2FA TOTP Verification with Trusted Device
Input: User has 2FA enabled and wants to sign in, marking device as trusted.
// Server: Configure 2FA with OTP sending
export const auth = betterAuth({
plugins: [
twoFactor({
issuer: 'MyApp',
otpOptions: {
async sendOTP({ user, otp }, ctx) {
await sendEmail({
to: user.email,
subject: 'Your verification code',
body: `Code: ${otp}`
});
}
}
})
]
});
// Client: Verify TOTP and trust device
const verify2FA = async (code: string) => {
const { data } = await authClient.twoFactor.verifyTotp({
code,
trustDevice: true // Device trusted for 30 days
});
if (data) {
router.push('/dashboard');
}
};Output: User authenticated; device trusted for 30 days without 2FA prompt.
Example 3: Passkey Registration and Login
Input: Enable passkey (WebAuthn) authentication for passwordless login.
// Server
import { passkey } from '@better-auth/passkey';
export const auth = betterAuth({
plugins: [
passkey({
rpID: 'example.com',
rpName: 'My App',
})
]
});
// Client: Register passkey
const registerPasskey = async () => {
const { data } = await authClient.passkey.register({
name: 'My Device'
});
};
// Client: Sign in with autofill
const signInWithPasskey = async () => {
await authClient.signIn.passkey({
autoFill: true, // Browser suggests passkey
});
};Output: Users can register and authenticate with biometrics, PIN, or security keys.
For more examples (backup codes, organizations, magic link, conditional UI), see references/plugins.md and references/passkey.md.
Best Practices
1. Environment Variables: Store all secrets in .env, add to .gitignore 2. Secret Generation: Use openssl rand -base64 32 for BETTER_AUTH_SECRET 3. HTTPS Required: OAuth callbacks need HTTPS (use ngrok for local testing) 4. Session Expiration: Configure based on security requirements (7 days default) 5. Database Indexing: Add indexes on email, userId for performance 6. Error Handling: Return generic errors without exposing sensitive details 7. Rate Limiting: Add to auth endpoints to prevent brute force attacks 8. Type Safety: Use npx better-auth typegen for full TypeScript coverage
Constraints and Warnings
Security Notes
- Never commit secrets: Add
.envto.gitignore; never commit OAuth secrets or DB credentials - Validate redirect URLs: Always validate OAuth redirect URLs to prevent open redirects
- Hash passwords: Better Auth handles password hashing automatically; never implement custom hashing
- Session storage: For production, use Redis or another scalable session store
- HTTPS Only: Always use HTTPS for authentication in production
- Email Verification: Always implement email verification for password-based auth
Known Limitations
- Better Auth requires Node.js 18+ for Next.js App Router support
- Some OAuth providers require specific redirect URL formats
- Passkeys require HTTPS and compatible browsers
- Organization features require additional database tables
Resources
Documentation
- Better Auth - Official documentation
- Drizzle ORM - Database ORM
- NestJS - Backend framework
- Next.js - Frontend framework
Reference Implementations
references/nestjs-setup.md- Complete NestJS backend setupreferences/nextjs-setup.md- Complete Next.js frontend setupreferences/plugins.md- Plugin configuration (2FA, passkey, organizations, SSO, magic link)references/mfa-2fa.md- Detailed MFA/2FA guidereferences/passkey.md- Detailed passkey implementationreferences/schema.md- Drizzle schema referencereferences/social-providers.md- Social provider configuration
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Better Auth
BETTER_AUTH_SECRET=your-secret-key-min-32-chars-generate-with-openssl-rand-base64-32
BETTER_AUTH_URL=http://localhost:3000
# Frontend URL (if separate from backend)
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
# GitHub OAuth
AUTH_GITHUB_CLIENT_ID=your_github_client_id
AUTH_GITHUB_CLIENT_SECRET=your_github_client_secret
# Google OAuth
AUTH_GOOGLE_CLIENT_ID=your_google_client_id
AUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret
# Microsoft OAuth
AUTH_MICROSOFT_CLIENT_ID=your_microsoft_client_id
AUTH_MICROSOFT_CLIENT_SECRET=your_microsoft_client_secret
AUTH_MICROSOFT_TENANT_ID=common
# Facebook OAuth
AUTH_FACEBOOK_CLIENT_ID=your_facebook_client_id
AUTH_FACEBOOK_CLIENT_SECRET=your_facebook_client_secret
# Discord OAuth
AUTH_DISCORD_CLIENT_ID=your_discord_client_id
AUTH_DISCORD_CLIENT_SECRET=your_discord_client_secret
# LinkedIn OAuth
AUTH_LINKEDIN_CLIENT_ID=your_linkedin_client_id
AUTH_LINKEDIN_CLIENT_SECRET=your_linkedin_client_secret
# Apple Sign In
AUTH_APPLE_CLIENT_ID=your_apple_client_id
AUTH_APPLE_CLIENT_SECRET=your_apple_client_secret
AUTH_APPLE_KEY_ID=your_apple_key_id
AUTH_APPLE_TEAM_ID=your_apple_team_id
# Email Configuration (for magic links and verification)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-smtp-user
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=noreply@example.com
SMTP_FROM_NAME=Your App Name
# Redis (for session storage in production)
REDIS_URL=redis://localhost:6379
# Session Configuration
SESSION_EXPIRY=604800 # 7 days in seconds
# 2FA Configuration
TWO_FACTOR_ISSUER=YourApp
# Organization Configuration
ORGANIZATION_ENABLED=true
# Rate Limiting
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW=900000 # 15 minutes in ms
import {
Controller,
Post,
Body,
Get,
Req,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Get('session')
@HttpCode(HttpStatus.OK)
async getSession(@Req() req) {
const token = req.headers.authorization?.replace('Bearer ', '');
return this.authService.getSession(token);
}
@Post('sign-out')
@UseGuards(AuthGuard)
@HttpCode(HttpStatus.OK)
async signOut(@Req() req) {
// Requires authentication to prevent session enumeration attacks
return this.authService.revokeSession(req.session.sessionToken);
}
@Get('sessions')
@UseGuards(AuthGuard)
async getSessions(@Req() req) {
return this.authService.getUserSessions(req.user.id);
}
@Post('sessions/revoke-all')
@UseGuards(AuthGuard)
@HttpCode(HttpStatus.OK)
async revokeAll(@Req() req) {
return this.authService.revokeAllSessions(req.user.id);
}
@Post('verify-email')
@HttpCode(HttpStatus.OK)
async sendVerificationEmail(@Body() body: { email: string }) {
return this.authService.sendVerificationEmail(body.email);
}
@Post('reset-password')
@HttpCode(HttpStatus.OK)
async resetPassword(
@Body() body: { token: string; newPassword: string }
) {
// IMPORTANT: Token must be a cryptographically secure random string
// sent to the user's email address via a forgot-password flow
return this.authService.resetPassword(body.token, body.newPassword);
}
}
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { auth } from './auth.instance';
@Injectable()
export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new UnauthorizedException('No token provided');
}
const session = await auth.api.getSession({
headers: new Headers({
authorization: `Bearer ${token}`,
}),
});
if (!session) {
throw new UnauthorizedException('Invalid session');
}
request.user = session.user;
request.session = session;
return true;
}
}
@Injectable()
export class OptionalAuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
request.user = null;
request.session = null;
return true;
}
try {
const session = await auth.api.getSession({
headers: new Headers({
authorization: `Bearer ${token}`,
}),
});
request.user = session?.user || null;
request.session = session || null;
} catch {
request.user = null;
request.session = null;
}
return true;
}
}
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthGuard, OptionalAuthGuard } from './auth.guard';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [AuthController],
providers: [AuthService, AuthGuard, OptionalAuthGuard],
exports: [AuthService, AuthGuard, OptionalAuthGuard],
})
export class AuthModule {}
// Drizzle schema for Better Auth with PostgreSQL
import {
pgTable,
text,
timestamp,
boolean,
primaryKey,
integer,
index,
} from 'drizzle-orm/pg-core';
import type { AdapterAccount } from '@auth/drizzle-adapter';
export const users = pgTable('user', {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email').notNull(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
}, (table) => ({
emailIdx: index('user_email_idx').on(table.email),
}));
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccount['type']>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
})
);
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull(),
createdAt: timestamp('created_at').defaultNow(),
}, (table) => ({
userIdIdx: index('session_user_id_idx').on(table.userId),
expiresIdx: index('session_expires_idx').on(table.expires),
}));
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
})
);
export const authenticators = pgTable(
'authenticator',
{
credentialID: text('credentialID').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
providerAccountId: text('providerAccountId').notNull(),
credentialPublicKey: text('credentialPublicKey').notNull(),
counter: integer('counter').notNull(),
credentialDeviceType: text('credentialDeviceType').notNull(),
credentialBackedUp: boolean('credentialBackedUp').notNull(),
transports: text('transports'),
},
(authenticator) => ({
compositePK: primaryKey({
columns: [authenticator.userId, authenticator.credentialID],
}),
})
);
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { auth } from './auth.instance';
@Injectable()
export class AuthService {
constructor(private db: DatabaseService) {}
async getSession(token: string) {
return auth.api.getSession({
headers: new Headers({
authorization: `Bearer ${token}`,
}),
});
}
async getUserSessions(userId: string) {
return auth.api.listSessions({
body: { userId },
});
}
async revokeSession(sessionToken: string) {
return auth.api.revokeSession({
body: { token: sessionToken },
});
}
async revokeAllSessions(userId: string) {
const sessions = await auth.api.listSessions({
body: { userId },
});
await Promise.all(
sessions.map((s) =>
auth.api.revokeSession({
body: { token: s.token },
})
)
);
}
async sendVerificationEmail(email: string) {
return auth.api.sendVerificationEmail({
body: { email },
});
}
async resetPassword(token: string, newPassword: string) {
// Token must be verified against the database before allowing password reset
// The token should be:
// - Cryptographically secure random string (min 32 bytes)
// - Time-limited (e.g., expires after 1 hour)
// - Single-use (deleted after verification)
// - Sent to the user's verified email address
return auth.api.resetPassword({
body: { token, newPassword },
});
}
}
import { Module, Global } from '@nestjs/common';
import { DatabaseService } from './database.service';
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './auth.schema';
@Injectable()
export class DatabaseService implements OnModuleDestroy {
private pool: Pool;
public db: ReturnType<typeof drizzle>;
constructor() {
this.pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
this.db = drizzle(this.pool, { schema });
}
async onModuleDestroy() {
await this.pool.end();
}
}
// Better Auth client configuration
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || 'http://localhost:3000',
});
// Server-side auth instance
import { betterAuth } from 'better-auth';
import { nextCookies } from 'better-auth/next-js';
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',
plugins: [],
});
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { SignOutButton } from '@/components/sign-out-button';
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect('/sign-in');
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="border-b bg-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex">
<div className="flex flex-shrink-0 items-center">
<h1 className="text-xl font-bold">Your App</h1>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-700">
{session.user.name || session.user.email}
</span>
<SignOutButton />
</div>
</div>
</div>
</nav>
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-2xl font-bold mb-4">Welcome back!</h2>
<div className="space-y-4">
<div>
<p className="text-sm font-medium text-gray-500">Name</p>
<p className="text-lg">{session.user.name || 'Not set'}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">Email</p>
<p className="text-lg">{session.user.email}</p>
</div>
{session.user.image && (
<div>
<p className="text-sm font-medium text-gray-500">Avatar</p>
<img
src={session.user.image}
alt="Avatar"
className="mt-1 h-16 w-16 rounded-full"
/>
</div>
)}
<div className="pt-4 border-t">
<p className="text-sm font-medium text-gray-500 mb-2">Session Data</p>
<pre className="bg-gray-100 p-4 rounded text-xs overflow-auto">
{JSON.stringify(session, null, 2)}
</pre>
</div>
</div>
</div>
</main>
</div>
);
}
import { auth } from '@/lib/auth';
import { nextMiddleware } from 'better-auth/next-js';
export default nextMiddleware(auth, {
// Optional: Add custom middleware logic
async before(request) {
// Add custom logic here if needed
return null;
},
});
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};
'use client';
import { authClient } from '@/lib/auth/client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { data, error } = await authClient.signIn.email({
email,
password,
});
if (error) {
setError(error.message);
return;
}
router.push('/dashboard');
router.refresh();
} catch {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleGitHubSignIn = async () => {
await authClient.signIn.social({
provider: 'github',
callbackURL: '/dashboard',
});
};
const handleGoogleSignIn = async () => {
await authClient.signIn.social({
provider: 'google',
callbackURL: '/dashboard',
});
};
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="text-3xl font-bold tracking-tight">
Sign in to your account
</h2>
</div>
{/* Social Sign In */}
<div className="space-y-3">
<button
onClick={handleGitHubSignIn}
className="w-full flex items-center justify-center gap-2 rounded-md border border-gray-300 px-4 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
Continue with GitHub
</button>
<button
onClick={handleGoogleSignIn}
className="w-full flex items-center justify-center gap-2 rounded-md border border-gray-300 px-4 py-2.5 text-sm font-medium hover:bg-gray-50 transition-colors"
>
<svg className="h-5 w-5" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
Continue with Google
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">
Or continue with email
</span>
</div>
</div>
{/* Email/Password Sign In */}
<form onSubmit={handleEmailSignIn} className="space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="text-center text-sm text-gray-600">
Don't have an account?{' '}
<a href="/sign-up" className="font-medium text-blue-600 hover:text-blue-500">
Sign up
</a>
</p>
</div>
</div>
);
}
'use client';
import { useEffect, useState } from 'react';
import type { Session } from 'better-auth/react';
export function useSession() {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadSession() {
try {
const response = await fetch('/api/auth/get-session');
if (response.ok) {
const data = await response.json();
setSession(data);
}
} catch {
setSession(null);
} finally {
setLoading(false);
}
}
loadSession();
}, []);
return { session, loading };
}
export function useUser() {
const { session, loading } = useSession();
return { user: session?.user, loading };
}
Better Auth Best Practices
Security guidelines, best practices, troubleshooting, and constraints.
Table of Contents
1. Best Practices 2. Constraints and Warnings 3. Troubleshooting
---
Best Practices
1. Environment Variables: Always use environment variables for sensitive data (secrets, database URLs, OAuth credentials)
2. Secret Generation: Use strong, unique secrets for Better Auth. Generate with openssl rand -base64 32
3. HTTPS Required: OAuth callbacks require HTTPS in production. Use ngrok or similar for local testing
4. Session Security: Configure appropriate session expiration times based on your security requirements
5. Database Indexing: Add indexes on frequently queried fields (email, userId) for performance
6. Error Handling: Implement proper error handling for auth failures without revealing sensitive information
7. Rate Limiting: Add rate limiting to auth endpoints to prevent brute force attacks
8. CSRF Protection: Better Auth includes CSRF protection. Always use the provided methods for state changes
9. Type Safety: Leverage TypeScript types from Better Auth for full type safety across frontend and backend
10. Testing: Test auth flows thoroughly including success cases, error cases, and edge conditions
---
Constraints and Warnings
Security Notes
- Never commit secrets: Add
.envto.gitignoreand never commit OAuth secrets or database credentials - Validate redirect URLs: Always validate OAuth redirect URLs to prevent open redirects
- Hash passwords: Better Auth handles password hashing automatically. Never implement your own
- Session storage: For production, use Redis or another scalable session store
- HTTPS Only: Always use HTTPS for authentication in production
- OAuth Secrets: Keep OAuth client secrets secure. Rotate them periodically
- Email Verification: Always implement email verification for password-based auth
Known Limitations
- Better Auth requires Node.js 18+ for Next.js App Router support
- Some OAuth providers require specific redirect URL formats
- Passkeys require HTTPS and compatible browsers
- Organization features require additional database tables
---
Troubleshooting
1. "Session not found" errors
Problem: Session data is not being persisted or retrieved correctly.
Solution:
- Verify database connection is working
- Check session table exists and has data
- Ensure
BETTER_AUTH_SECRETis set consistently - Verify cookie domain settings match your application domain
2. OAuth callback fails with "Invalid state"
Problem: OAuth state mismatch during callback.
Solution:
- Clear cookies and try again
- Ensure
BETTER_AUTH_URLis set correctly in environment - Check that redirect URI in OAuth app matches exactly
- Verify no reverse proxy is modifying callbacks
3. TypeScript type errors with auth()
Problem: Type inference not working correctly.
Solution:
- Ensure TypeScript 5+ is installed
- Use
npx better-auth typegento generate types - Restart TypeScript server in your IDE
- Check that
better-authversions match on frontend and backend
4. Migration fails with "table already exists"
Problem: Drizzle migration conflicts.
Solution:
- Drop existing tables and re-run migration
- Or use
drizzle-kit pushfor development - For production, write manual migration to handle existing tables
5. CORS errors from frontend to backend
Problem: Frontend cannot communicate with backend auth endpoints.
Solution:
- Configure CORS in NestJS backend
- Add frontend origin to allowed origins
- Ensure credentials are included:
credentials: 'include'
6. Social provider returns "redirect_uri_mismatch"
Problem: OAuth app configuration mismatch.
Solution:
- Update OAuth app with exact callback URL
- Include both http://localhost and production URLs
- For ngrok/local testing, update OAuth app each time URL changes
---
See Also
- Examples - Detailed implementation examples
- Patterns - Common patterns and configuration
- NestJS Setup - Complete NestJS backend setup
- Next.js Setup - Complete Next.js frontend setup
- MFA/2FA - Multi-factor authentication details
- Passkey - Passkey authentication details
Better Auth Examples
This document contains detailed examples for implementing Better Auth in various scenarios.
Table of Contents
1. Complete NestJS Auth Setup 2. Next.js Middleware for Route Protection 3. Server Component with Session 4. Adding Two-Factor Authentication 5. TOTP Verification with Trusted Device 6. Passkey Authentication Setup 7. Passkey Conditional UI (Autofill) 8. Backup Codes for 2FA Recovery
---
Example 1: Complete NestJS Auth Setup
Input: Developer needs to set up Better Auth in a new NestJS project with PostgreSQL.
Process:
// 1. Create auth instance
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema: { ...schema }
}),
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}
}
});
// 2. Create auth controller
@Controller('auth')
export class AuthController {
@All('*')
async handleAuth(@Req() req: Request, @Res() res: Response) {
return auth.handler(req);
}
}Output: Fully functional auth endpoints at /auth/* with GitHub OAuth support.
---
Example 2: Next.js Middleware for Route Protection
Input: Protect dashboard routes in Next.js App Router.
Process:
// middleware.ts
import { auth } from '@/lib/auth';
export default auth((req) => {
if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) {
const newUrl = new URL('/sign-in', req.nextUrl.origin);
return Response.redirect(newUrl);
}
});
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*']
};Output: Unauthenticated users are redirected to /sign-in when accessing /dashboard/*.
---
Example 3: Server Component with Session
Input: Display user data in a Next.js Server Component.
Process:
// app/dashboard/page.tsx
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect('/sign-in');
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
</div>
);
}Output: Renders user information only for authenticated users, redirects others to sign-in.
---
Example 4: Adding Two-Factor Authentication
Input: Enable 2FA for enhanced account security.
Process:
// Enable 2FA plugin
export const auth = betterAuth({
plugins: [
twoFactor({
issuer: 'MyApp',
otpOptions: {
digits: 6,
period: 30
}
})
]
});
// Client-side enable 2FA
const { data, error } = await authClient.twoFactor.enable({
password: 'user-password'
});Output: Users can enable TOTP-based 2FA and verify with authenticator apps.
---
Example 5: TOTP Verification with Trusted Device
Input: User has enabled 2FA and wants to sign in, marking the device as trusted.
Process:
// Server-side: Configure 2FA with OTP sending
export const auth = betterAuth({
plugins: [
twoFactor({
issuer: 'MyApp',
otpOptions: {
async sendOTP({ user, otp }, ctx) {
// Send OTP via email, SMS, or other method
await sendEmail({
to: user.email,
subject: 'Your verification code',
body: `Code: ${otp}`
});
}
}
})
]
});
// Client-side: Verify TOTP and trust device
const verify2FA = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyTotp({
code,
trustDevice: true // Device trusted for 30 days
});
if (data) {
// Redirect to dashboard
router.push('/dashboard');
}
};Output: User is authenticated, device is trusted for 30 days (no 2FA prompt on next sign-ins).
---
Example 6: Passkey Authentication Setup
Input: Enable passkey (WebAuthn) authentication for passwordless login.
Process:
// Server-side: Configure passkey plugin
import { passkey } from '@better-auth/passkey';
export const auth = betterAuth({
plugins: [
passkey({
rpID: 'example.com', // Relying Party ID (your domain)
rpName: 'My App', // Display name
advanced: {
webAuthnChallengeCookie: 'my-app-passkey'
}
})
]
});
// Client-side: Register passkey
const registerPasskey = async () => {
const { data, error } = await authClient.passkey.register({
name: 'My Device'
});
if (data) {
console.log('Passkey registered successfully');
}
};
// Client-side: Sign in with passkey
const signInWithPasskey = async () => {
await authClient.signIn.passkey({
autoFill: true, // Enable conditional UI
fetchOptions: {
onSuccess() {
router.push('/dashboard');
}
}
});
};Output: Users can register and authenticate with passkeys (biometric, PIN, or security key).
---
Example 7: Passkey Conditional UI (Autofill)
Input: Implement passkey autofill in sign-in form for seamless authentication.
Process:
// Component with conditional UI support
'use client';
import { useEffect } from 'react';
import { authClient } from '@/lib/auth/client';
export default function SignInPage() {
useEffect(() => {
// Check for conditional mediation support
if (!PublicKeyCredential.isConditionalMediationAvailable ||
!PublicKeyCredential.isConditionalMediationAvailable()) {
return;
}
// Enable passkey autofill
void authClient.signIn.passkey({ autoFill: true });
}, []);
return (
<form>
<label htmlFor="email">Email:</label>
<input
type="email"
name="email"
autoComplete="username webauthn"
/>
<label htmlFor="password">Password:</label>
<input
type="password"
name="password"
autoComplete="current-password webauthn"
/>
<button type="submit">Sign In</button>
</form>
);
}Output: Browser automatically suggests passkeys when user focuses on input fields.
---
Example 8: Backup Codes for 2FA Recovery
Input: User needs backup codes to recover account if authenticator app is lost.
Process:
// Enable 2FA - backup codes are generated automatically
const enable2FA = async (password: string) => {
const { data, error } = await authClient.twoFactor.enable({
password
});
if (data) {
// IMPORTANT: Display backup codes to user immediately
console.log('Backup codes (save these securely):');
data.backupCodes.forEach((code: string) => {
console.log(code);
});
// Show TOTP URI as QR code
const qrCodeUrl = data.totpURI;
displayQRCode(qrCodeUrl);
}
};
// Recover with backup code
const recoverWithBackupCode = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyBackupCode({
code
});
if (data) {
// Allow user to disable 2FA or set up new authenticator
router.push('/settings/2fa');
}
};Output: User receives single-use backup codes for account recovery.
---
See Also
- NestJS Setup - Complete NestJS backend setup
- Next.js Setup - Complete Next.js frontend setup
- MFA/2FA - Multi-factor authentication details
- Passkey - Passkey authentication details
- Plugins - Plugin configuration
- Schema - Database schema setup
Better Auth MFA/2FA Guide
Overview
Better Auth provides comprehensive Multi-Factor Authentication (MFA) support through the twoFactor plugin. This includes TOTP-based authentication, backup codes for recovery, and trusted device management.
Installation
npm install better-authThe twoFactor plugin is included in the main better-auth package.
Server Configuration
Basic Setup
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
appName: "My App",
plugins: [
twoFactor({
issuer: "My App", // Displayed in authenticator apps
otpOptions: {
async sendOTP({ user, otp }, ctx) {
// Required: Send OTP to user via email, SMS, etc.
await sendEmail({
to: user.email,
subject: 'Your verification code',
body: `Your code is: ${otp}`
});
}
}
})
]
});Configuration Options
| Option | Type | Required | Description |
|---|---|---|---|
issuer | string | No | App name displayed in authenticator apps (default: "Better Auth") |
otpOptions.sendOTP | function | Yes | Async function to send OTP to user |
otpOptions.digits | number | No | Number of OTP digits (default: 6) |
otpOptions.period | number | No | OTP validity period in seconds (default: 30) |
Client-Side Implementation
Enable 2FA
import { authClient } from "@/lib/auth/client";
// Enable 2FA for current user
const enable2FA = async (password: string) => {
const { data, error } = await authClient.twoFactor.enable({
password
});
if (data) {
// Display TOTP URI as QR code
const qrCodeUrl = data.totpURI;
// Example: otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp
// Show backup codes (single-use recovery codes)
console.log('Save these backup codes securely:');
data.backupCodes.forEach((code: string) => {
console.log(code);
});
}
};Response:
totpURI: URI for QR code generationbackupCodes: Array of single-use recovery codes
Verify TOTP
const verifyTOTP = async (code: string, trustDevice: boolean = true) => {
const { data, error } = await authClient.twoFactor.verifyTotp({
code,
trustDevice // Trust device for 30 days
});
if (data) {
// 2FA verified, user is authenticated
router.push('/dashboard');
}
};Verify with OTP (Email/SMS)
const verifyOTP = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyOtp({
code,
trustDevice: true
});
if (data) {
router.push('/dashboard');
}
};Use Backup Code
const recoverWithBackupCode = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyBackupCode({
code
});
if (data) {
// User can disable 2FA or set up new authenticator
router.push('/settings/2fa');
}
};Get TOTP URI
const getTOTPUri = async (password: string) => {
const { data, error } = await authClient.twoFactor.getTotpUri({
password
});
if (data) {
// Display QR code from data.totpURI
displayQRCode(data.totpURI);
}
};Disable 2FA
const disable2FA = async (password: string) => {
const { data, error } = await authClient.twoFactor.disable({
password
});
if (data) {
// 2FA disabled
}
};Trusted Devices
How It Works
- Pass
trustDevice: truewhen verifying 2FA - Device is trusted for 30 days
- Trust period refreshes on each successful sign-in
- Device-specific (not user-wide)
Implementation
const signInWith2FA = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyTotp({
code,
trustDevice: true // Device won't require 2FA for 30 days
});
if (data) {
// User authenticated, device is now trusted
}
};Applicable Endpoints
verifyTotp- TOTP code verificationverifyOtp- OTP (email/SMS) verificationverifyBackupCode- Backup code verification
API Endpoints
POST /two-factor/enable
Enable 2FA for current user.
Body:
{
"password": "user-password",
"issuer": "My App" // Optional
}Response:
{
"totpURI": "otpauth://totp/...",
"backupCodes": ["CODE1", "CODE2", "CODE3", "CODE4", "CODE5"]
}POST /two-factor/verify-totp
Verify TOTP code.
Body:
{
"code": "123456",
"trustDevice": true
}POST /two-factor/verify-otp
Verify OTP sent via email/SMS.
Body:
{
"code": "123456",
"trustDevice": true
}POST /two-factor/verify-backup-code
Verify single-use backup code.
Body:
{
"code": "BACKUP-CODE"
}POST /two-factor/disable
Disable 2FA for current user.
Body:
{
"password": "user-password"
}POST /two-factor/get-totp-uri
Get TOTP URI for reconfiguring authenticator.
Body:
{
"password": "user-password"
}Security Best Practices
1. Backup Codes
- Display immediately after enabling 2FA
- Store in secure location (password manager)
- Cannot be retrieved later
2. OTP Delivery
- Use secure channels (HTTPS)
- Implement rate limiting
- Set short expiration times
3. TOTP Configuration
- Use 6 digits (standard)
- 30-second period (standard)
- Allow ±1 period drift for clock skew
4. Trusted Devices
- Clear trust on password change
- Allow users to view/revoke trusted devices
- Consider shorter trust period for sensitive apps
React Component Example
'use client';
import { useState } from 'react';
import { authClient } from '@/lib/auth/client';
export function TwoFactorSetup() {
const [step, setStep] = useState<'password' | 'qr' | 'backup'>('password');
const [password, setPassword] = useState('');
const [qrCode, setQrCode] = useState('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const handleEnable = async () => {
const { data, error } = await authClient.twoFactor.enable({ password });
if (data) {
setQrCode(data.totpURI);
setBackupCodes(data.backupCodes);
setStep('qr');
}
};
return (
<div>
{step === 'password' && (
<div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
/>
<button onClick={handleEnable}>Enable 2FA</button>
</div>
)}
{step === 'qr' && (
<div>
<img src={`/api/qr?data=${encodeURIComponent(qrCode)}`} alt="Scan with authenticator" />
<button onClick={() => setStep('backup')}>I've scanned the code</button>
</div>
)}
{step === 'backup' && (
<div>
<h3>Save these backup codes securely:</h3>
<ul>
{backupCodes.map((code) => (
<li key={code}>{code}</li>
))}
</ul>
<p>These codes can be used to recover your account if you lose access to your authenticator.</p>
</div>
)}
</div>
);
}Troubleshooting
"Invalid TOTP code"
- Check device time synchronization
- Ensure authenticator app uses correct time
- Try regenerating TOTP URI
"Backup code already used"
- Each backup code is single-use
- Use a different backup code
- Disable and re-enable 2FA to generate new codes
"Device not trusted"
trustDevicemust betrueduring verification- Check browser cookie settings
- Clear cookies and re-authenticate
Better Auth NestJS Setup Guide
Version Requirements
{
"dependencies": {
"better-auth": "^1.1.0",
"@auth/drizzle-adapter": "^1.0.0",
"drizzle-orm": "^0.35.0",
"pg": "^8.12.0",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/platform-express": "^10.0.0"
},
"devDependencies": {
"drizzle-kit": "^0.24.0",
"@types/pg": "^8.11.0",
"@types/node": "^20.0.0"
}
}Installation
npm install better-auth @auth/drizzle-adapter drizzle-orm pg
npm install @nestjs/common @nestjs/core @nestjs/config @nestjs/platform-express
npm install -D drizzle-kit @types/pgStep 1: Database Configuration
Create Drizzle Config
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
import 'dotenv/config';
export default defineConfig({
schema: './src/auth/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});Create Database Module
// src/database/database.module.ts
import { Module, Global } from '@nestjs/common';
import { DatabaseService } from './database.service';
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}// src/database/database.service.ts
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
@Injectable()
export class DatabaseService implements OnModuleDestroy {
private pool: Pool;
public db: ReturnType<typeof drizzle>;
constructor() {
this.pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
this.db = drizzle(this.pool, {
schema: {},
});
}
async onModuleDestroy() {
await this.pool.end();
}
}Step 2: Better Auth Schema
// src/auth/schema.ts
import {
pgTable,
text,
timestamp,
boolean,
primaryKey,
integer,
} from 'drizzle-orm/pg-core';
import type { AdapterAccount } from '@auth/drizzle-adapter';
export const users = pgTable('user', {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email').notNull(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccount['type']>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
})
);
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull(),
});
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
})
);
export const authenticators = pgTable(
'authenticator',
{
credentialID: text('credentialID').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
providerAccountId: text('providerAccountId').notNull(),
credentialPublicKey: text('credentialPublicKey').notNull(),
counter: integer('counter').notNull(),
credentialDeviceType: text('credentialDeviceType').notNull(),
credentialBackedUp: boolean('credentialBackedUp').notNull(),
transports: text('transports'),
},
(authenticator) => ({
compositePK: primaryKey({
columns: [authenticator.userId, authenticator.credentialID],
}),
})
);Step 3: Better Auth Instance
// src/auth/auth.instance.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from '@auth/drizzle-adapter';
import * as schema from './schema';
export const auth = betterAuth({
database: drizzleAdapter(schema, {
provider: 'postgresql',
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
socialProviders: {
github: {
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
enabled: true,
},
google: {
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
enabled: true,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
},
advanced: {
cookiePrefix: 'better-auth',
crossSubDomainCookies: {
enabled: false,
},
},
});Step 4: Auth Service
// src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { auth } from './auth.instance';
import { headers } from 'next/headers';
@Injectable()
export class AuthService {
constructor(private db: DatabaseService) {}
async getSession(token: string) {
return auth.api.getSession({
headers: new Headers({
authorization: `Bearer ${token}`,
}),
});
}
async getUserSessions(userId: string) {
return auth.api.listSessions({
body: { userId },
});
}
async revokeSession(sessionToken: string) {
return auth.api.revokeSession({
body: { token: sessionToken },
});
}
async revokeAllSessions(userId: string) {
const sessions = await auth.api.listSessions({
body: { userId },
});
await Promise.all(
sessions.map((s) =>
auth.api.revokeSession({
body: { token: s.token },
})
)
);
}
}Step 5: Auth Controller
// src/auth/auth.controller.ts
import { Controller, Post, Body, Get, Req, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Get('session')
async getSession(@Req() req) {
const token = req.headers.authorization?.replace('Bearer ', '');
return this.authService.getSession(token);
}
@Post('sign-out')
@UseGuards(AuthGuard)
async signOut(@Req() req) {
// Requires authentication to prevent session enumeration attacks
return this.authService.revokeSession(req.session.sessionToken);
}
@Get('sessions')
@UseGuards(AuthGuard)
async getSessions(@Req() req) {
return this.authService.getUserSessions(req.user.id);
}
@Post('sessions/revoke-all')
@UseGuards(AuthGuard)
async revokeAll(@Req() req) {
return this.authService.revokeAllSessions(req.user.id);
}
}Step 6: Auth Guard
// src/auth/auth.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { auth } from './auth.instance';
@Injectable()
export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new UnauthorizedException('No token provided');
}
const session = await auth.api.getSession({
headers: new Headers({
authorization: `Bearer ${token}`,
}),
});
if (!session) {
throw new UnauthorizedException('Invalid session');
}
request.user = session.user;
return true;
}
}Step 7: Auth Module
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';
import { DatabaseModule } from '../database/database.module';
@Module({
imports: [DatabaseModule],
controllers: [AuthController],
providers: [AuthService, AuthGuard],
exports: [AuthService, AuthGuard],
})
export class AuthModule {}Step 8: Generate and Run Migrations
# Generate migration files
npx drizzle-kit generate
# Run migrations
npx drizzle-kit migrate
# Or push directly (development only)
npx drizzle-kit pushEnvironment Variables
# .env
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
BETTER_AUTH_SECRET=your-secret-key-min-32-chars
BETTER_AUTH_URL=http://localhost:3000
AUTH_GITHUB_CLIENT_ID=your-github-client-id
AUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
AUTH_GOOGLE_CLIENT_ID=your-google-client-id
AUTH_GOOGLE_CLIENT_SECRET=your-google-client-secretUsage in Other Controllers
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
@Controller('api/protected')
@UseGuards(AuthGuard)
export class ProtectedController {
@Get()
getProtectedData(@Request() req) {
// req.user is available here
return { message: 'Protected data', user: req.user };
}
}Better Auth Next.js Setup Guide
Version Requirements
{
"dependencies": {
"better-auth": "^1.2.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0"
}
}Installation
npm install better-auth
npm install next@latest react@latest react-dom@latestStep 1: Create Auth Client
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { nextCookies } from 'better-auth/next-js';
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',
plugins: [],
});// lib/auth/client.ts
import { createAuthClient } from 'better-auth/react';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || 'http://localhost:3000',
});Step 2: Create Auth API Route
// app/api/auth/[...auth]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);Step 3: Create Auth Pages
Sign In Page
// app/(auth)/sign-in/page.tsx
'use client';
import { authClient } from '@/lib/auth/client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SignInPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { data, error } = await authClient.signIn.email({
email,
password,
});
if (error) {
setError(error.message);
return;
}
router.push('/dashboard');
router.refresh();
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
const handleGitHubSignIn = async () => {
await authClient.signIn.social({
provider: 'github',
callbackURL: '/dashboard',
});
};
const handleGoogleSignIn = async () => {
await authClient.signIn.social({
provider: 'google',
callbackURL: '/dashboard',
});
};
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="text-3xl font-bold">Sign in to your account</h2>
</div>
{/* Social Sign In */}
<div className="space-y-4">
<button
onClick={handleGitHubSignIn}
className="w-full rounded-md border p-3 hover:bg-gray-50"
>
Continue with GitHub
</button>
<button
onClick={handleGoogleSignIn}
className="w-full rounded-md border p-3 hover:bg-gray-50"
>
Continue with Google
</button>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">Or continue with</span>
</div>
</div>
{/* Email/Password Sign In */}
<form onSubmit={handleEmailSignIn} className="space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email address
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border p-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border p-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 p-3 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
<p className="text-center text-sm">
Don't have an account?{' '}
<a href="/sign-up" className="text-blue-600 hover:underline">
Sign up
</a>
</p>
</div>
</div>
);
}Sign Up Page
// app/(auth)/sign-up/page.tsx
'use client';
import { authClient } from '@/lib/auth/client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SignUpPage() {
const router = useRouter();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { data, error } = await authClient.signUp.email({
email,
password,
name,
});
if (error) {
setError(error.message);
return;
}
router.push('/dashboard');
router.refresh();
} catch (err) {
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md space-y-8">
<div>
<h2 className="text-3xl font-bold">Create your account</h2>
</div>
<form onSubmit={handleSignUp} className="space-y-6">
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-800">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium">
Full name
</label>
<input
id="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-md border p-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email address
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-md border p-2"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-md border p-2"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 p-3 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Sign up'}
</button>
</form>
<p className="text-center text-sm">
Already have an account?{' '}
<a href="/sign-in" className="text-blue-600 hover:underline">
Sign in
</a>
</p>
</div>
</div>
);
}Step 4: Protected Page Example
// app/(dashboard)/dashboard/page.tsx
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect('/sign-in');
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
<pre>{JSON.stringify(session, null, 2)}</pre>
</div>
);
}Step 5: Sign Out Component
// components/sign-out-button.tsx
'use client';
import { authClient } from '@/lib/auth/client';
import { useRouter } from 'next/navigation';
export function SignOutButton() {
const router = useRouter();
const handleSignOut = async () => {
await authClient.signOut();
router.push('/sign-in');
router.refresh();
};
return (
<button onClick={handleSignOut}>
Sign out
</button>
);
}Step 6: Middleware for Route Protection
// middleware.ts
import { auth } from '@/lib/auth';
import { nextMiddleware } from 'better-auth/next-js';
export default nextMiddleware(auth);
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};Or with custom middleware:
// middleware.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
});
const isAuthPage = request.nextUrl.pathname.startsWith('/sign-in') ||
request.nextUrl.pathname.startsWith('/sign-up');
const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');
if (isAuthPage && session) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
if (isProtectedRoute && !session) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};Step 7: Client-side Session Hook
// hooks/use-session.ts
'use client';
import { useEffect, useState } from 'react';
import type { Session } from 'better-auth/types';
export function useSession() {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadSession() {
try {
const response = await fetch('/api/auth/get-session');
const data = await response.json();
setSession(data);
} catch {
setSession(null);
} finally {
setLoading(false);
}
}
loadSession();
}, []);
return { session, loading };
}Step 8: Server Actions for Auth
// app/actions.ts
'use server';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
export async function getSession() {
return auth.api.getSession({
headers: await headers(),
});
}
export async function signOut() {
return auth.api.signOut({
headers: await headers(),
});
}Environment Variables
# .env.local
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
# If using external backend:
# BETTER_AUTH_URL=https://api.example.com
# NEXT_PUBLIC_BETTER_AUTH_URL=https://app.example.comApp Router Structure
app/
├── (auth)/
│ ├── sign-in/
│ │ └── page.tsx
│ └── sign-up/
│ └── page.tsx
├── (dashboard)/
│ ├── dashboard/
│ │ └── page.tsx
│ └── layout.tsx
├── api/
│ └── auth/
│ └── [...auth]/
│ └── route.ts
├── layout.tsx
└── page.tsxBetter Auth Passkey (WebAuthn) Guide
Overview
Better Auth supports passkey authentication through the @better-auth/passkey plugin. Passkeys provide passwordless authentication using biometrics (fingerprint, face recognition), device PIN, or physical security keys.
Installation
npm install @better-auth/passkeyServer Configuration
Basic Setup
import { betterAuth } from "better-auth";
import { passkey } from "@better-auth/passkey";
export const auth = betterAuth({
plugins: [
passkey({
rpID: 'example.com', // Your domain (Relying Party ID)
rpName: 'My App', // Display name in passkey prompts
})
]
});Advanced Configuration
import { betterAuth } from "better-auth";
import { passkey } from "@better-auth/passkey";
export const auth = betterAuth({
plugins: [
passkey({
rpID: 'example.com',
rpName: 'My App',
advanced: {
// Custom cookie name for WebAuthn challenge
webAuthnChallengeCookie: 'my-app-passkey'
}
})
]
});Configuration Options
| Option | Type | Required | Description |
|---|---|---|---|
rpID | string | Yes | Relying Party ID (your domain) |
rpName | string | Yes | Display name shown in passkey prompts |
advanced.webAuthnChallengeCookie | string | No | Custom cookie name for challenge |
Client Configuration
Setup Auth Client
import { createAuthClient } from 'better-auth/client';
import { passkeyClient } from '@better-auth/passkey/client';
export const authClient = createAuthClient({
plugins: [passkeyClient()],
});Passkey Registration
Register New Passkey
const registerPasskey = async () => {
const { data, error } = await authClient.passkey.register({
name: 'My MacBook Pro' // Descriptive name for this passkey
});
if (data) {
console.log('Passkey registered successfully');
} else {
console.error('Registration failed:', error);
}
};User Experience
1. Browser prompts user to verify (Touch ID, Face ID, PIN, etc.) 2. Passkey is created and stored on the device 3. Public key is sent to server for storage 4. User can now sign in with this passkey
Passkey Authentication
Sign In with Passkey
const signInWithPasskey = async () => {
await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
window.location.href = '/dashboard';
},
onError(context) {
console.error('Authentication failed:', context.error.message);
}
}
});
};Sign In with Conditional UI (Autofill)
Conditional UI allows the browser to automatically suggest passkeys when users interact with input fields.
'use client';
import { useEffect } from 'react';
import { authClient } from '@/lib/auth/client';
export default function SignInPage() {
useEffect(() => {
// Check for conditional mediation support
if (!PublicKeyCredential.isConditionalMediationAvailable ||
!PublicKeyCredential.isConditionalMediationAvailable()) {
return;
}
// Enable passkey autofill
void authClient.signIn.passkey({
autoFill: true
});
}, []);
return (
<form>
<label htmlFor="email">Email:</label>
<input
type="email"
name="email"
autoComplete="username webauthn"
/>
<label htmlFor="password">Password:</label>
<input
type="password"
name="password"
autoComplete="current-password webauthn"
/>
<button type="submit">Sign In</button>
</form>
);
}Key Requirements for Conditional UI
1. Input field attributes: Add autoComplete="... webauthn" to inputs 2. Component mount: Call signIn.passkey({ autoFill: true }) on mount 3. Browser support: Check PublicKeyCredential.isConditionalMediationAvailable()
Managing Passkeys
List User Passkeys
const listPasskeys = async () => {
const { data, error } = await authClient.passkey.listUserPasskeys();
if (data) {
data.passkeys.forEach((passkey) => {
console.log(`ID: ${passkey.id}, Name: ${passkey.name}`);
});
}
};Delete Passkey
const deletePasskey = async (passkeyId: string) => {
const { data, error } = await authClient.passkey.delete({
id: passkeyId
});
if (data) {
console.log('Passkey deleted');
}
};Update Passkey Name
const updatePasskeyName = async (passkeyId: string, newName: string) => {
const { data, error } = await authClient.passkey.update({
id: passkeyId,
name: newName
});
if (data) {
console.log('Passkey name updated');
}
};React Component Examples
Passkey Registration Button
'use client';
import { useState } from 'react';
import { authClient } from '@/lib/auth/client';
export function RegisterPasskeyButton() {
const [isRegistering, setIsRegistering] = useState(false);
const handleRegister = async () => {
setIsRegistering(true);
const { data, error } = await authClient.passkey.register({
name: `${navigator.platform} - ${new Date().toLocaleDateString()}`
});
setIsRegistering(false);
if (data) {
alert('Passkey registered successfully!');
} else {
alert(`Registration failed: ${error?.message}`);
}
};
return (
<button
onClick={handleRegister}
disabled={isRegistering}
>
{isRegistering ? 'Registering...' : 'Register Passkey'}
</button>
);
}Passkey Sign In Button
'use client';
import { useState } from 'react';
import { authClient } from '@/lib/auth/client';
import { useRouter } from 'next/navigation';
export function PasskeySignInButton() {
const [isAuthenticating, setIsAuthenticating] = useState(false);
const router = useRouter();
const handleSignIn = async () => {
setIsAuthenticating(true);
await authClient.signIn.passkey({
fetchOptions: {
onSuccess() {
router.push('/dashboard');
},
onError(context) {
alert(`Authentication failed: ${context.error.message}`);
setIsAuthenticating(false);
}
}
});
};
return (
<button
onClick={handleSignIn}
disabled={isAuthenticating}
className="passkey-button"
>
{isAuthenticating ? 'Authenticating...' : 'Sign in with Passkey'}
</button>
);
}Passkey Management Panel
'use client';
import { useEffect, useState } from 'react';
import { authClient } from '@/lib/auth/client';
interface Passkey {
id: string;
name: string;
createdAt: Date;
}
export function PasskeyManagement() {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
useEffect(() => {
loadPasskeys();
}, []);
const loadPasskeys = async () => {
const { data } = await authClient.passkey.listUserPasskeys();
if (data) {
setPasskeys(data.passkeys);
}
};
const handleDelete = async (id: string) => {
const confirmed = confirm('Delete this passkey?');
if (!confirmed) return;
const { data } = await authClient.passkey.delete({ id });
if (data) {
setPasskeys(passkeys.filter((p) => p.id !== id));
}
};
return (
<div>
<h2>Your Passkeys</h2>
<ul>
{passkeys.map((passkey) => (
<li key={passkey.id}>
{passkey.name}
<button onClick={() => handleDelete(passkey.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}API Endpoints
POST /passkey/register
Register a new passkey for the current user.
Body:
{
"name": "My Device"
}POST /sign-in/passkey
Authenticate using a registered passkey.
Body:
{
"autoFill": false
}GET /passkey/list
List all passkeys registered for the current user.
POST /passkey/delete
Delete a specific passkey.
Body:
{
"id": "passkey-id"
}POST /passkey/update
Update the name of a passkey.
Body:
{
"id": "passkey-id",
"name": "New Name"
}Security Considerations
HTTPS Requirement
- Passkeys require HTTPS in production
- Use
ngrokor similar for local HTTPS testing
Relying Party ID
- Must match the actual domain (no wildcards)
- For localhost testing, use
localhost - For production, use your actual domain
Cross-Device Authentication
- Passkeys synced via cloud (Apple ID, Google account) work across devices
- Platform authenticators (Touch ID, Face ID) are device-specific
- Security keys (YubiKey) work on any device with USB/NFC
Backup Authentication
- Always provide alternative authentication (password, email)
- Some users may lose access to their passkey device
Browser Support
| Browser | Passkey Support | Conditional UI |
|---|---|---|
| Chrome 108+ | Yes | Yes |
| Safari 16+ | Yes | Yes |
| Firefox 122+ | Yes | Limited |
| Edge 108+ | Yes | Yes |
Platform Support
iOS/macOS
- Touch ID / Face ID
- iCloud Keychain sync
- Security keys (NFC, Lightning, USB-C)
Android
- Fingerprint / Face unlock
- Google Password Manager sync
- Security keys (NFC, USB)
Windows
- Windows Hello (PIN, fingerprint, face)
- Security keys (USB, NFC)
Troubleshooting
"NotAllowedError"
- User cancelled the operation
- No authenticator available
- Browser security settings blocking WebAuthn
"SecurityError"
rpIDdoesn't match current domain- Not using HTTPS in production
- Invalid origin
Conditional UI not working
- Check browser support with
isConditionalMediationAvailable() - Ensure
autoComplete="webauthn"is on input fields - Verify user has registered passkeys
Passkey not appearing
- Check if passkey is synced to current device
- Verify same user account (Apple ID, Google account)
- Try manual sign-in button instead of conditional UI
Best Practices
1. Hybrid Approach: Support both passkeys and passwords 2. Clear Labels: Use descriptive passkey names (device + date) 3. Conditional UI: Enable autofill for better UX 4. Fallback: Always provide password fallback 5. Security: Enforce HTTPS in production 6. Testing: Test on multiple devices and browsers
Better Auth Patterns
Common implementation patterns, version requirements, and configuration reference.
Table of Contents
1. Protected Route Pattern 2. Session Management Pattern 3. Version Requirements 4. Environment Variables
---
Protected Route Pattern
NestJS Guard
@Controller('dashboard')
@UseGuards(AuthGuard)
export class DashboardController {
@Get()
getDashboard(@Request() req) {
return req.user;
}
}Next.js Server Component
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const session = await auth();
if (!session) {
redirect('/sign-in');
}
return <div>Welcome {session.user.name}</div>;
}---
Session Management Pattern
Get Session in API Route
const session = await auth.api.getSession({
headers: await headers()
});Get Session in Server Component
const session = await auth();Get Session in Client Component
'use client';
import { useSession } from '@/lib/auth/client';
const { data: session } = useSession();---
Version Requirements
Backend Dependencies
{
"dependencies": {
"better-auth": "^1.2.0",
"@auth/drizzle-adapter": "^1.0.0",
"drizzle-orm": "^0.35.0",
"pg": "^8.12.0",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/config": "^3.0.0"
},
"devDependencies": {
"drizzle-kit": "^0.24.0",
"@types/pg": "^8.11.0"
}
}Frontend Dependencies
{
"dependencies": {
"better-auth": "^1.2.0",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}Database
- PostgreSQL 14+ recommended
- For local development: Docker PostgreSQL or Postgres.app
---
Environment Variables
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Better Auth
BETTER_AUTH_SECRET=your-secret-key-min-32-chars
BETTER_AUTH_URL=http://localhost:3000
# OAuth Providers
AUTH_GITHUB_CLIENT_ID=your-github-client-id
AUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
AUTH_GOOGLE_CLIENT_ID=your-google-client-id
AUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret
# Email (for magic links and verification)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=noreply@example.com
# Session (optional, for Redis)
REDIS_URL=redis://localhost:6379---
See Also
- Examples - Detailed implementation examples
- Best Practices - Security and operational best practices
- NestJS Setup - Complete NestJS backend setup
- Next.js Setup - Complete Next.js frontend setup
Better Auth Plugins Guide
Better Auth supports various plugins for extending authentication functionality. This guide covers the most commonly used plugins.
Available Plugins
- Two-Factor Authentication (2FA)
- Organization
- SSO (Single Sign-On)
- Magic Link
- Passkey
- Email Verification
- Phone Verification
- Anonymous User
Two-Factor Authentication (2FA)
Detailed Guide: See MFA_2FA.md for complete implementation guide.
Installation
The twoFactor plugin is included in the main better-auth package.
npm install better-authBackend Configuration
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
appName: 'My App',
plugins: [
twoFactor({
issuer: 'My App',
otpOptions: {
async sendOTP({ user, otp }, ctx) {
// Required: Send OTP to user via email, SMS, etc.
await sendEmail({
to: user.email,
subject: 'Your verification code',
body: `Your code is: ${otp}`
});
}
}
}),
],
});Frontend Usage
'use client';
import { authClient } from '@/lib/auth/client';
export function TwoFactorSetup() {
const enable2FA = async (password: string) => {
const { data, error } = await authClient.twoFactor.enable({ password });
// data.totpURI - QR code for authenticator
// data.backupCodes - Single-use recovery codes
};
const verifyTOTP = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyTotp({
code,
trustDevice: true // Trust device for 30 days
});
};
const recoverWithBackup = async (code: string) => {
const { data, error } = await authClient.twoFactor.verifyBackupCode({
code
});
};
return (
<div>
<button onClick={() => enable2FA('password')}>Enable 2FA</button>
</div>
);
}Features
- TOTP: Time-based one-time passwords (Google Authenticator, Authy)
- OTP via Email/SMS: Alternative verification method
- Backup Codes: Single-use recovery codes for account recovery
- Trusted Devices: Skip 2FA for 30 days on trusted devices
Organization Plugin
Backend Configuration
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
organization({
// Organization configuration
avatar: {
enabled: true,
},
currency: 'USD',
}),
],
});Database Schema
// Add to schema.ts
export const organizations = pgTable('organizations', {
id: text('id').notNull().primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
logo: text('logo'),
createdAt: timestamp('createdAt').defaultNow(),
members: text('members').array(),
});
export const member = pgTable('member', {
id: text('id').notNull().primaryKey(),
organizationId: text('organizationId')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
role: text('role').notNull(), // owner, admin, member
createdAt: timestamp('createdAt').defaultNow(),
});Frontend Usage
'use client';
import { authClient } from '@/lib/auth/client';
export function OrganizationManager() {
const createOrg = async (name: string) => {
const { data, error } = await authClient.organization.create({
name,
slug: name.toLowerCase().replace(/\s+/g, '-'),
});
};
const inviteMember = async (email: string) => {
const { data, error } = await authClient.organization.inviteMember({
email,
role: 'member',
});
};
return (
<div>
<button onClick={() => createOrg('My Org')}>
Create Organization
</button>
</div>
);
}SSO (Single Sign-On)
Backend Configuration
import { betterAuth } from 'better-auth';
import { sso } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
sso({
// SSO configuration
}),
],
});SAML Setup
// For enterprise SSO with SAML
export const auth = betterAuth({
plugins: [
sso({
saml: {
enabled: true,
},
}),
],
});Magic Link Plugin
Backend Configuration
import { betterAuth } from 'better-auth';
import { magicLink } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
// Send email with magic link
await sendEmail({
to: email,
subject: 'Sign in to Your App',
html: `<a href="${url}">Sign in</a>`,
});
},
// Magic link expiration (default 24h)
expiresIn: 1000 * 60 * 15, // 15 minutes
}),
],
});Frontend Usage
'use client';
import { authClient } from '@/lib/auth/client';
export function MagicLinkSignIn() {
const sendMagicLink = async (email: string) => {
const { data, error } = await authClient.magicLink.send({
email,
});
if (!error) {
alert('Check your email for a sign-in link');
}
};
return (
<form onSubmit={(e) => {
e.preventDefault();
const email = e.target.email.value;
sendMagicLink(email);
}}>
<input name="email" type="email" required />
<button type="submit">Send Magic Link</button>
</form>
);
}Passkey Plugin
Detailed Guide: See passkey.md for complete implementation guide.
Installation
npm install @better-auth/passkeyBackend Configuration
import { betterAuth } from 'better-auth';
import { passkey } from '@better-auth/passkey';
export const auth = betterAuth({
plugins: [
passkey({
rpID: 'example.com', // Your domain
rpName: 'My App', // Display name
}),
],
});Client Configuration
import { createAuthClient } from 'better-auth/client';
import { passkeyClient } from '@better-auth/passkey/client';
export const authClient = createAuthClient({
plugins: [passkeyClient()],
});Frontend Usage
'use client';
import { authClient } from '@/lib/auth/client';
export function PasskeyAuth() {
const registerPasskey = async () => {
const { data, error } = await authClient.passkey.register({
name: 'My Device'
});
};
const signInWithPasskey = async () => {
await authClient.signIn.passkey({
autoFill: true, // Enable conditional UI
});
};
return (
<div>
<button onClick={signInWithPasskey}>
Sign in with Passkey
</button>
</div>
);
}Features
- WebAuthn: Standard passkey authentication (biometric, PIN, security key)
- Conditional UI: Browser autofill for passkeys
- Cross-Device: Synced via iCloud Keychain, Google Password Manager
- Security Keys: YubiKey and hardware authenticator support
Email Verification Plugin
Backend Configuration
import { betterAuth } from 'better-auth';
import { emailVerification } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
emailVerification({
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: 'Verify your email',
html: `<a href="${url}">Verify email</a>`,
});
},
// Send verification email on sign up
sendOnSignUp: true,
}),
],
});Frontend Usage
'use client';
import { authClient } from '@/lib/auth/client';
export function EmailVerification() {
const resendVerification = async () => {
const { data, error } = await authClient.emailVerification.send({
email: 'user@example.com',
});
};
const verifyEmail = async (code: string) => {
const { data, error } = await authClient.emailVerification.verify({
code,
});
};
return (
<div>
<button onClick={resendVerification}>
Resend verification email
</button>
</div>
);
}Plugin Combination Example
import { betterAuth } from 'better-auth';
import { twoFactor, organization, magicLink, passkey } from 'better-auth/plugins';
export const auth = betterAuth({
database: drizzleAdapter(schema, {
provider: 'postgresql',
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
socialProviders: {
github: {
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
enabled: true,
},
},
plugins: [
twoFactor({
totp: { enabled: true },
}),
organization({
avatar: { enabled: true },
}),
magicLink({
sendMagicLink: async ({ email, url }) => {
// Custom email sending logic
},
}),
passkey(),
],
});Plugin-Specific Migrations
After adding plugins, remember to generate new migrations:
npx drizzle-kit generate
npx drizzle-kit migrateEnvironment Variables for Plugins
# .env
# 2FA
TWO_FACTOR_SECRET=your-totp-secret
# Email
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=noreply@example.com
# SSO (SAML)
SAML_CERT_PATH=/path/to/cert.pem
SAML_KEY_PATH=/path/to/key.pemBetter Auth Database Schema Reference
This document provides the complete Drizzle ORM schema for Better Auth with PostgreSQL.
Core Tables
User Table
import {
pgTable,
text,
timestamp,
boolean,
primaryKey,
integer,
} from 'drizzle-orm/pg-core';
export const users = pgTable('user', {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email').notNull(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});Account Table (OAuth)
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<'email' | 'oauth' | 'oidc' | 'webauthn'>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
})
);Session Table
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull(),
});Verification Token Table
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
})
);Plugin Tables
Two-Factor Authentication
export const totp = pgTable('totp', {
id: text('id').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
secret: text('secret').notNull(),
createdAt: timestamp('createdAt').defaultNow(),
});Organization Tables
export const organizations = pgTable('organizations', {
id: text('id').notNull().primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
logo: text('logo'),
metadata: text('metadata').$type<Record<string, any>>(),
createdAt: timestamp('createdAt').defaultNow(),
});
export const members = pgTable('members', {
id: text('id').notNull().primaryKey(),
organizationId: text('organizationId')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
role: text('role').notNull(), // owner, admin, member
createdAt: timestamp('createdAt').defaultNow(),
});
export const invitations = pgTable('invitations', {
id: text('id').notNull().primaryKey(),
organizationId: text('organizationId')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
role: text('role').notNull(),
status: text('status').notNull(), // pending, accepted, rejected
expiresAt: timestamp('expiresAt').notNull(),
createdAt: timestamp('createdAt').defaultNow(),
});Passkey Table
export const authenticators = pgTable(
'authenticator',
{
credentialID: text('credentialID').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
providerAccountId: text('providerAccountId').notNull(),
credentialPublicKey: text('credentialPublicKey').notNull(),
counter: integer('counter').notNull(),
credentialDeviceType: text('credentialDeviceType').notNull(),
credentialBackedUp: boolean('credentialBackedUp').notNull(),
transports: text('transports'),
name: text('name'),
},
(authenticator) => ({
compositePK: primaryKey({
columns: [authenticator.userId, authenticator.credentialID],
}),
})
);Magic Link Table
export const magicLinks = pgTable('magic_links', {
id: text('id').notNull().primaryKey(),
email: text('email').notNull(),
token: text('token').notNull().unique(),
expiresAt: timestamp('expiresAt').notNull(),
createdAt: timestamp('createdAt').defaultNow(),
});Email Verification Table
export const emailVerifications = pgTable('email_verifications', {
id: text('id').notNull().primaryKey(),
email: text('email').notNull(),
code: text('code').notNull(),
expiresAt: timestamp('expiresAt').notNull(),
verified: boolean('verified').notNull().default(false),
createdAt: timestamp('createdAt').defaultNow(),
});Complete Schema File
// src/auth/schema.ts
import {
pgTable,
text,
timestamp,
boolean,
primaryKey,
integer,
index,
} from 'drizzle-orm/pg-core';
import type { AdapterAccount } from '@auth/drizzle-adapter';
// User table
export const users = pgTable('user', {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email').notNull(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
}, (table) => ({
emailIdx: index('user_email_idx').on(table.email),
}));
// Account table
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccount['type']>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
})
);
// Session table
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull(),
}, (table) => ({
userIdIdx: index('session_user_id_idx').on(table.userId),
}));
// Verification token table
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
})
);
// Authenticator table (for passkeys)
export const authenticators = pgTable(
'authenticator',
{
credentialID: text('credentialID').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
providerAccountId: text('providerAccountId').notNull(),
credentialPublicKey: text('credentialPublicKey').notNull(),
counter: integer('counter').notNull(),
credentialDeviceType: text('credentialDeviceType').notNull(),
credentialBackedUp: boolean('credentialBackedUp').notNull(),
transports: text('transports'),
name: text('name'),
},
(authenticator) => ({
compositePK: primaryKey({
columns: [authenticator.userId, authenticator.credentialID],
}),
})
);Migrations
Generate Migration
npx drizzle-kit generateRun Migration
npx drizzle-kit migratePush to Database (Dev Only)
npx drizzle-kit pushType Generation
Generate TypeScript types from your schema:
npx drizzle-kit generate:pgThis will create types for all your tables that you can use in your application:
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;Indexes
For better performance, add indexes to commonly queried fields:
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull(),
createdAt: timestamp('created_at').defaultNow(),
}, (table) => ({
userIdIdx: index('session_user_id_idx').on(table.userId),
expiresIdx: index('session_expires_idx').on(table.expires),
}));Relationships
The schema establishes these relationships:
- Account -> User: Many-to-one (accounts belong to users)
- Session -> User: Many-to-one (sessions belong to users)
- Authenticator -> User: Many-to-one (passkeys belong to users)
- Member -> User: Many-to-one (organization members are users)
- Member -> Organization: Many-to-one (members belong to organizations)
Better Auth Social Providers Guide
Better Auth supports 40+ social login providers. This guide covers the most commonly used providers and their setup.
Supported Providers
- GitHub
- Microsoft (Azure AD)
- Twitter / X
- Apple
- Discord
- Spotify
- Twitch
- GitLab
- Bitbucket
- Amazon
- Yahoo
- Yandex
- And 25+ more
GitHub OAuth Setup
1. Create GitHub OAuth App
1. Go to https://github.com/settings/developers 2. Click "New OAuth App" 3. Fill in the details:
- Application name: Your App Name
- Homepage URL:
http://localhost:3000(dev) or your production URL - Authorization callback URL:
http://localhost:3000/api/auth/callback/github
2. Configure Environment Variables
# .env
AUTH_GITHUB_CLIENT_ID=your_github_client_id
AUTH_GITHUB_CLIENT_SECRET=your_github_client_secret3. Enable in Better Auth
// src/auth/auth.instance.ts
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
socialProviders: {
github: {
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
enabled: true,
},
},
});4. Frontend Sign In
'use client';
import { authClient } from '@/lib/auth/client';
export function GitHubSignIn() {
const handleGitHubSignIn = async () => {
await authClient.signIn.social({
provider: 'github',
callbackURL: '/dashboard',
});
};
return (
<button onClick={handleGitHubSignIn}>
Continue with GitHub
</button>
);
}Google OAuth Setup
1. Create Google OAuth Client
1. Go to https://console.cloud.google.com/ 2. Create a new project or select existing 3. Navigate to "APIs & Services" > "Credentials" 4. Click "Create Credentials" > "OAuth client ID" 5. Configure consent screen if prompted 6. Application type: Web application 7. Add authorized redirect URI: http://localhost:3000/api/auth/callback/google
2. Configure Environment Variables
# .env
AUTH_GOOGLE_CLIENT_ID=your_google_client_id
AUTH_GOOGLE_CLIENT_SECRET=your_google_client_secret3. Enable in Better Auth
export const auth = betterAuth({
socialProviders: {
google: {
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
enabled: true,
},
},
});Microsoft (Azure AD) Setup
1. Create Azure AD App
1. Go to https://portal.azure.com/ 2. Navigate to "Azure Active Directory" > "App registrations" 3. Click "New registration" 4. Redirect URI: http://localhost:3000/api/auth/callback/microsoft 5. Copy Application (client) ID 6. Generate client secret
2. Configure Environment Variables
# .env
AUTH_MICROSOFT_CLIENT_ID=your_microsoft_client_id
AUTH_MICROSOFT_CLIENT_SECRET=your_microsoft_client_secret
AUTH_MICROSOFT_TENANT_ID=common # or your tenant ID3. Enable in Better Auth
export const auth = betterAuth({
socialProviders: {
microsoft: {
clientId: process.env.AUTH_MICROSOFT_CLIENT_ID!,
clientSecret: process.env.AUTH_MICROSOFT_CLIENT_SECRET!,
tenantId: process.env.AUTH_MICROSOFT_TENANT_ID || 'common',
enabled: true,
},
},
});Facebook OAuth Setup
1. Create Facebook App
1. Go to https://developers.facebook.com/apps/ 2. Create app (type: Consumer) 3. Add "Facebook Login" product 4. Set redirect URI: http://localhost:3000/api/auth/callback/facebook
2. Configure Environment Variables
# .env
AUTH_FACEBOOK_CLIENT_ID=your_facebook_app_id
AUTH_FACEBOOK_CLIENT_SECRET=your_facebook_app_secretApple Sign In Setup
1. Create Apple Sign In Service ID
1. Go to https://developer.apple.com/account/resources/ 2. Create "Services ID" 3. Configure Sign In with Apple 4. Set redirect URL: https://yourdomain.com/api/auth/callback/apple
2. Configure Environment Variables
# .env
AUTH_APPLE_CLIENT_ID=your_apple_client_id
AUTH_APPLE_CLIENT_SECRET=your_apple_client_secret # Generated from JWT
AUTH_APPLE_KEY_ID=your_apple_key_id
AUTH_APPLE_TEAM_ID=your_apple_team_id3. Generate Apple Client Secret
Apple requires a JWT signed with your private key:
import jwt from 'jsonwebtoken';
import fs from 'fs';
function generateAppleClientSecret() {
const privateKey = fs.readFileSync('./AuthKey.p8');
const token = jwt.sign({}, privateKey, {
algorithm: 'ES256',
keyid: process.env.AUTH_APPLE_KEY_ID,
issuer: process.env.AUTH_APPLE_TEAM_ID,
audience: 'https://appleid.apple.com',
subject: process.env.AUTH_APPLE_CLIENT_ID,
expiresIn: '180d',
});
return token;
}Discord OAuth Setup
1. Create Discord Application
1. Go to https://discord.com/developers/applications 2. Create application 3. OAuth2 > Redirects: http://localhost:3000/api/auth/callback/discord
2. Configure Environment Variables
# .env
AUTH_DISCORD_CLIENT_ID=your_discord_client_id
AUTH_DISCORD_CLIENT_SECRET=your_discord_client_secretLinkedIn OAuth Setup
1. Create LinkedIn App
1. Go to https://www.linkedin.com/developers/ 2. Create app 3. Auth redirect URLs: http://localhost:3000/api/auth/callback/linkedin
2. Configure Environment Variables
# .env
AUTH_LINKEDIN_CLIENT_ID=your_linkedin_client_id
AUTH_LINKEDIN_CLIENT_SECRET=your_linkedin_client_secretProduction Configuration
For production, update your callback URLs:
export const auth = betterAuth({
socialProviders: {
github: {
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
enabled: true,
redirectURI: `${process.env.BETTER_AUTH_URL}/api/auth/callback/github`,
},
google: {
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
enabled: true,
redirectURI: `${process.env.BETTER_AUTH_URL}/api/auth/callback/google`,
},
},
});Multiple Providers Example
export const auth = betterAuth({
socialProviders: {
github: {
clientId: process.env.AUTH_GITHUB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
enabled: true,
},
google: {
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
enabled: true,
},
microsoft: {
clientId: process.env.AUTH_MICROSOFT_CLIENT_ID!,
clientSecret: process.env.AUTH_MICROSOFT_CLIENT_SECRET!,
enabled: true,
},
discord: {
clientId: process.env.AUTH_DISCORD_CLIENT_ID!,
clientSecret: process.env.AUTH_DISCORD_CLIENT_SECRET!,
enabled: true,
},
},
});Custom Provider Configuration
Some providers require additional configuration:
export const auth = betterAuth({
socialProviders: {
google: {
clientId: process.env.AUTH_GOOGLE_CLIENT_ID!,
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
enabled: true,
// Additional scopes
scopes: ['openid', 'profile', 'email'],
// Access type
accessType: 'offline',
// Prompt
prompt: 'consent',
},
},
});Provider-Specific Scopes
Different providers support different scopes:
// Google scopes
google: {
scopes: ['openid', 'profile', 'email'],
}
// GitHub scopes
github: {
scopes: ['read:user', 'user:email'],
}
// Microsoft scopes
microsoft: {
scopes: ['openid', 'profile', 'email'],
}
// Discord scopes
discord: {
scopes: ['identify', 'email'],
}Testing OAuth Locally
Using ngrok for HTTPS
Some providers (like Apple) require HTTPS for OAuth callbacks:
# Install ngrok
npm install -g ngrok
# Start ngrok
ngrok http 3000
# Use the HTTPS URL for OAuth configuration
# e.g., https://abc123.ngrok.ioUpdate your environment:
# .env.local
NEXT_PUBLIC_APP_URL=https://abc123.ngrok.io
BETTER_AUTH_URL=https://abc123.ngrok.ioTroubleshooting
"redirect_uri_mismatch" Error
- Ensure redirect URI matches exactly (including trailing slashes)
- For ngrok, update OAuth app each time URL changes
- Check both allowed redirect URIs and authorized JavaScript origins
"Invalid client" Error
- Verify client ID and secret are correct
- Check for extra spaces in environment variables
- Ensure OAuth app is not in test mode (for some providers)
Scope Issues
- Request only the scopes you need
- Some scopes require additional verification with the provider
- Check provider documentation for required scopes
Environment Variables Template
# GitHub
AUTH_GITHUB_CLIENT_ID=
AUTH_GITHUB_CLIENT_SECRET=
# Google
AUTH_GOOGLE_CLIENT_ID=
AUTH_GOOGLE_CLIENT_SECRET=
# Microsoft
AUTH_MICROSOFT_CLIENT_ID=
AUTH_MICROSOFT_CLIENT_SECRET=
AUTH_MICROSOFT_TENANT_ID=common
# Facebook
AUTH_FACEBOOK_CLIENT_ID=
AUTH_FACEBOOK_CLIENT_SECRET=
# Discord
AUTH_DISCORD_CLIENT_ID=
AUTH_DISCORD_CLIENT_SECRET=
# LinkedIn
AUTH_LINKEDIN_CLIENT_ID=
AUTH_LINKEDIN_CLIENT_SECRET=
# Apple
AUTH_APPLE_CLIENT_ID=
AUTH_APPLE_CLIENT_SECRET=
AUTH_APPLE_KEY_ID=
AUTH_APPLE_TEAM_ID=Related skills
Forks & variants (1)
Better Auth has 1 known copy in the catalog totaling 21 installs. They canonicalize to this original listing.
- giuseppe-trisciuoglio - 21 installs
How it compares
Use when standardizing on the Better Auth library with PostgreSQL and multi-provider OAuth rather than generic auth architecture advice.
FAQ
What does better-auth do?
Integrate Better Auth with NestJS backend and Next.js App Router using Drizzle and PostgreSQL.
When should I use better-auth?
User sets up Better Auth with NestJS, Next.js, Drizzle, or PostgreSQL.
Is better-auth safe to install?
Review the Security Audits panel on this page before installing in production.