
Better Auth
- 362 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
better-auth is a secondsky/claude-skills agent skill aimed at helping developers integrate the Better Auth authentication library into TypeScript and full-stack applications when building login, session, and OAuth flows.
About
better-auth is a skill slug in secondsky/claude-skills described for development tasks involving the Better Auth ecosystem, a TypeScript-first authentication library commonly used with modern web frameworks. The catalog excerpt provides no SKILL.md body, so specifics such as provider plugins, database adapters, or CLI commands cannot be verified from the source snippet alone. Developers typically reach for better-auth when wiring email/password, OAuth, or session management into Next.js or Node backends and want agent guidance aligned to Better Auth conventions. Treat confidence as moderate-low until the repository readme confirms covered frameworks and setup steps.
- better-auth
Better Auth by the numbers
- 362 all-time installs (skills.sh)
- +11 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,133 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill better-authAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 362 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
How do you add Better Auth to a TypeScript app?
Use better-auth for development tasks
Who is it for?
Full-stack developers adopting Better Auth in TypeScript web apps who want agent guidance on auth integration patterns.
Skip if: Teams standardized on a different auth system such as Auth0-only SaaS with no Better Auth adoption planned.
When should I use this skill?
A task mentions Better Auth, TypeScript authentication setup, OAuth providers, or session management in a Better Auth-based project.
What you get
Better Auth integration steps, auth route configuration, and session or OAuth provider setup guidance for the target framework.
Files
better-auth
Status: Production Ready Last Updated: 2026-04-08 Package: better-auth@1.6.0 (ESM-only) Dependencies: Drizzle ORM or Kysely (required for D1 complex use cases; D1 native support available in v1.5+)
---
Quick Start (5 Minutes)
Installation
Option 1: Drizzle ORM (Recommended)
bun add better-auth drizzle-orm drizzle-kitOption 2: Kysely
bun add better-auth kysely @noxharmonium/kysely-d1⚠️ v1.4.0+ Requirements
better-auth v1.4.0+ is ESM-only. Ensure:
package.json:
{
"type": "module"
}Upgrading from v1.3.x? Load references/migration-guide-1.4.0.md Upgrading from v1.4.x? Load references/migration-guide-1.5.0.md
⚠️ CRITICAL: D1 Adapter Requirements
v1.5.0+: D1 is now natively supported. Pass your D1 binding directly:
// ✅ SIMPLEST - D1 native (v1.5.0+)
import { betterAuth } from "better-auth";
const auth = betterAuth({
database: env.DB, // D1 binding, auto-detected
});For complex schemas, use Drizzle ORM:
// ✅ RECOMMENDED for complex schemas - Drizzle
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/d1";
const auth = betterAuth({
database: drizzleAdapter(drizzle(env.DB, { schema }), { provider: "sqlite" }),
});// ❌ WRONG - This doesn't exist
import { d1Adapter } from 'better-auth/adapters/d1'Minimal Setup (Cloudflare Workers + Drizzle)
1. Create D1 Database:
wrangler d1 create my-app-db2. Define Schema (src/db/schema.ts):
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
id: text().primaryKey(),
name: text().notNull(),
email: text().notNull().unique(),
emailVerified: integer({ mode: "boolean" }).notNull().default(false),
image: text(),
});
export const session = sqliteTable("session", {
id: text().primaryKey(),
userId: text().notNull().references(() => user.id, { onDelete: "cascade" }),
token: text().notNull(),
expiresAt: integer({ mode: "timestamp" }).notNull(),
});
// See references/database-schema.ts for complete schema3. Initialize Auth (src/auth.ts):
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./db/schema";
export function createAuth(env: { DB: D1Database; BETTER_AUTH_SECRET: string }) {
const db = drizzle(env.DB, { schema });
return betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: "sqlite" }),
emailAndPassword: { enabled: true },
});
}4. Create Worker (src/index.ts):
import { Hono } from "hono";
import { createAuth } from "./auth";
const app = new Hono<{ Bindings: Env }>();
app.all("/api/auth/*", async (c) => {
const auth = createAuth(c.env);
return auth.handler(c.req.raw);
});
export default app;5. Deploy:
bunx drizzle-kit generate
wrangler d1 migrations apply my-app-db --remote
wrangler deploy---
Decision Tree
For code examples and syntax, always consult [better-auth.com/docs](https://better-auth.com/docs).
Is this a new/empty project?
├─ YES → New project setup
│ 1. Identify framework (Next.js, Nuxt, Workers, etc.)
│ 2. Choose database (D1, PostgreSQL, MongoDB, MySQL)
│ 3. Install better-auth + Drizzle/Kysely
│ 4. Create auth.ts + auth-client.ts
│ 5. Set up route handler (see Quick Start above)
│ 6. Run migrations (Drizzle Kit for D1)
│ 7. Add features via plugins (2FA, organizations, etc.)
│
└─ NO → Does project have existing auth?
├─ YES → Migration/enhancement
│ • Audit current auth for gaps
│ • Plan incremental migration
│ • See references/framework-comparison.md for migration guides
│
└─ NO → Add auth to existing project
1. Analyze project structure
2. Install better-auth + adapter
3. Create auth config (see Quick Start)
4. Add route handler to existing routes
5. Run schema migrations
6. Integrate into existing pages/components---
Critical Rules
MUST DO
✅ Use better-auth/minimal + adapter packages for smallest bundle (v1.5+) ✅ Use npx auth migrate and npx auth generate for CLI commands (v1.5+) ✅ Set BETTER_AUTH_SECRET via wrangler secret put ✅ Configure CORS with credentials: true ✅ Match OAuth callback URLs exactly (no trailing slash) ✅ Apply migrations to local D1 before wrangler dev ✅ Use camelCase column names in schema
NEVER DO
❌ Use d1Adapter (doesn't exist) ❌ Forget CORS credentials or mismatch OAuth URLs ❌ Use snake_case columns without CamelCasePlugin ❌ Skip local migrations or hardcode secrets ❌ Leave sendVerificationEmail unimplemented
⚠️ v1.5.0 Breaking Changes
API Key Plugin Moved:
- import { apiKey } from "better-auth/plugins";
+ import { apiKey } from "@better-auth/api-key";Schema: userId → referenceId, new configId field.
After Hooks: Database after-hooks now run post-transaction (not inside it).
Deprecated APIs Removed: Adapter → DBAdapter, InferUser/InferSession removed, @better-auth/core/utils split into subpath exports.
Load `references/migration-guide-1.5.0.md` when upgrading from <1.5.0
⚠️ v1.6.0 Breaking Changes
Session Freshness: freshAge now uses createdAt (not updatedAt). Sessions may require re-auth more frequently for sensitive operations.
SAML Security: InResponseTo validation is default ON. Opt out with saml: { enableInResponseToValidation: false }.
OIDC Provider Deprecated: Use @better-auth/oauth-provider instead.
New in v1.5.0 (Highlights)
- New CLI:
npx auth init/migrate/generate/upgrade - D1 Native: Pass D1 binding directly (no adapter needed)
- OAuth 2.1 Provider:
@better-auth/oauth-provider(MCP-ready) - Electron:
@better-auth/electronfor desktop apps - i18n:
@better-auth/i18nfor error translations - Dynamic Base URL: Multi-domain/preview deployment support
- Secret Key Rotation: Non-destructive, versioned secrets
- Test Utils: Factories, OTP capture, login helpers
- Typed Error Codes: Machine-readable
codein error responses
Load `references/v1.5-features.md` for detailed implementation guides.
New in v1.6.0 (Highlights)
- OpenTelemetry: Distributed tracing (experimental)
- Passkey Pre-Auth: Register passkeys before session
- Non-blocking Scrypt: Password hashing on libuv thread pool
- 46% Smaller Package: 4.2MB → 2.3MB
- Case Insensitive Queries:
mode: "insensitive"on adapter queries
Load `references/v1.6-features.md` for detailed implementation guides.
---
Quick Reference
Environment Variables
| Variable | Purpose | Example |
|---|---|---|
BETTER_AUTH_SECRET | Encryption secret (min 32 chars) | Generate: openssl rand -base64 32 |
BETTER_AUTH_URL | Base URL | https://example.com or http://localhost:8787 |
DATABASE_URL | Database connection (optional for D1) | PostgreSQL/MySQL connection string |
Note: Only define baseURL/secret in config if env vars are NOT set.
CLI Commands (v1.5+)
| Command | Purpose |
|---|---|
npx auth init | Interactive project scaffolding |
npx auth migrate | Run database migrations |
npx auth generate | Generate auth schema |
npx auth generate --adapter drizzle | Adapter-specific schema |
npx auth upgrade | Upgrade to latest version |
bunx drizzle-kit generate | D1: Use this to generate Drizzle migrations |
wrangler d1 migrations apply DB_NAME | D1: Use this to apply migrations |
Re-run after adding/changing plugins.
Core Config Options
| Option | Notes |
|---|---|
appName | Optional display name |
baseURL | Only if BETTER_AUTH_URL not set |
basePath | Default /api/auth. Set / for root. |
secret | Only if BETTER_AUTH_SECRET not set (min 32 chars) |
database | Required for most features. Use drizzleAdapter() or Kysely for D1 |
secondaryStorage | Redis/KV for sessions & rate limits |
emailAndPassword | { enabled: true } to activate |
socialProviders | { google: { clientId, clientSecret }, ... } |
plugins | Array of plugins (import from dedicated paths) |
trustedOrigins | CSRF whitelist for cross-origin requests |
Common Plugins
Import from dedicated packages (extracted in v1.5+):
import { twoFactor } from "better-auth/plugins/two-factor"
import { organization } from "better-auth/plugins/organization"
import { passkey } from "@better-auth/passkey" // Separate package
import { apiKey } from "@better-auth/api-key" // Separate package (v1.5+)
import { sso } from "@better-auth/sso" // Separate package (v1.5+)
import { i18n } from "@better-auth/i18n" // Separate package (v1.5+)
import { oauthProvider } from "@better-auth/oauth-provider" // Separate package (v1.5+)Core plugins (still in better-auth/plugins): twoFactor, organization, admin, anonymous, emailOTP, magicLink, phone-number, multi-session, custom-session.
---
Top 5 Errors (See references/error-catalog.md for all 15)
Error #1: "d1Adapter is not exported"
Problem: Trying to use non-existent d1Adapter Solution: Use drizzleAdapter or Kysely instead (see Quick Start above)
Error #2: Schema Generation Fails
Problem: better-auth migrate doesn't work with D1 Solution: Use bunx drizzle-kit generate then wrangler d1 migrations apply
Error #3: CamelCase vs snake_case Mismatch
Problem: Database uses email_verified but better-auth expects emailVerified Solution: Use camelCase in schema or add CamelCasePlugin to Kysely
Error #4: CORS Errors
Problem: Access-Control-Allow-Origin errors, cookies not sent Solution: Configure CORS with credentials: true and correct origins
Error #5: OAuth Redirect URI Mismatch
Problem: Social sign-in fails with "redirect_uri_mismatch" Solution: Ensure exact match: https://yourdomain.com/api/auth/callback/google
Load `references/error-catalog.md` for all 15 errors with detailed solutions.
---
Common Use Cases
Use Case 1: Email/Password Authentication
When: Basic authentication without social providers Quick Pattern:
// Client
await authClient.signIn.email({
email: "user@example.com",
password: "password123",
});
// Server - enable in config
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
}Load: references/setup-guide.md → Step 5
Use Case 2: Social Authentication (45+ Providers)
When: Allow users to sign in with social accounts Supported: Google, GitHub, Microsoft, Apple, Discord, TikTok, Twitch, Spotify, LinkedIn, Slack, Reddit, Facebook, Twitter/X, Patreon, Vercel, Kick, and 30+ more. Quick Pattern:
// Client
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
// Server config
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"],
},
}Load: references/setup-guide.md → Step 5
Use Case 3: Protected API Routes
When: Need to verify user is authenticated Quick Pattern:
app.get("/api/protected", async (c) => {
const auth = createAuth(c.env);
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
return c.json({ data: "protected", user: session.user });
});Load: references/cloudflare-worker-drizzle.ts
Use Case 4: Multi-Tenant with Organizations
When: Building SaaS with teams/organizations Load: references/advanced-features.md → Organizations & Teams
Use Case 5: Two-Factor Authentication
When: Need extra security with 2FA/TOTP Load: references/advanced-features.md → Two-Factor Authentication
---
When to Load References
Load `references/setup-guide.md` when:
- User needs complete 8-step setup walkthrough
- User asks about Kysely adapter alternative
- User needs help with migrations or deployment
- User asks about wrangler.toml configuration
Load `references/error-catalog.md` when:
- Encountering any of the 15 documented errors
- User reports D1 adapter, schema, CORS, or OAuth issues
- User asks about troubleshooting or debugging
- User needs prevention checklist
Load `references/advanced-features.md` when:
- User asks about 2FA, passkeys, or magic links
- User needs organizations, teams, or RBAC
- User asks about rate limiting or session management
- User wants migration guide from Clerk or Auth.js
- User needs security best practices or performance optimization
Load `references/v1.5-features.md` when:
- User asks about the new CLI, MCP auth, OAuth 2.1 Provider
- User needs Electron desktop auth or i18n error translations
- User asks about dynamic base URL or secret key rotation
- User needs D1 native support (no adapter) or adapter extraction
- User asks about test utils, seat-based billing, or typed error codes
- User needs Cloudflare D1 native support configuration
Load `references/v1.6-features.md` when:
- User asks about OpenTelemetry or distributed tracing
- User needs passkey pre-auth registration (before session)
- User asks about case-insensitive database queries
- User encounters session freshness issues after upgrading
- User asks about SAML InResponseTo validation
Load `references/migration-guide-1.5.0.md` when:
- User upgrading from better-auth <1.5.0 to 1.5.0+
- User encounters API Key import errors (
userId→referenceId) - User asks about after hooks running post-transaction
- User encounters
InferUser/InferSessiontype errors - User needs to update
@better-auth/core/utilsimports
Load `references/plugins/sso.md` when:
- User needs production SSO with OIDC, OAuth2, or SAML 2.0
- User asks about OIDC discovery, SAML SLO, or domain verification
- User needs organization provisioning via SSO
- User asks about SAML security (InResponseTo, replay protection, timestamps)
- User encounters SSO discovery errors
Load `references/plugins/test-utils.md` when:
- User writing integration or E2E tests with Better Auth
- User needs test factories (createUser, createOrganization)
- User needs authenticated test sessions (login, getAuthHeaders, getCookies)
- User needs OTP capture for verification tests
Load `references/integrations/electron.md` when:
- User building Electron desktop app with Better Auth
- User needs system browser OAuth flow for desktop
- User asks about deep links, custom protocol schemes
- User needs IPC bridges or manual token exchange
Load `references/cloudflare-worker-drizzle.ts` when:
- User needs complete Worker implementation example
- User asks for production-ready code
- User wants to see full auth flow with protected routes
Load `references/cloudflare-worker-kysely.ts` when:
- User prefers Kysely over Drizzle
- User asks for Kysely-specific implementation
Load `references/database-schema.ts` when:
- User needs complete better-auth schema with all tables
- User asks about custom tables or schema extension
- User needs TypeScript types for database
Load `references/react-client-hooks.tsx` when:
- User building React/Next.js frontend
- User needs login forms, session hooks, or protected routes
- User asks about client-side implementation
Load `references/configuration-guide.md` when:
- User asks about production configuration
- User needs environment variable setup or wrangler.toml
- User asks about dynamic base URL, secret rotation, or D1 native
- User needs CORS configuration, rate limiting, or API keys
- User asks about session configuration (deferSessionRefresh, verification on secondary storage)
Load `references/framework-comparison.md` when:
- User asks "better-auth vs Clerk" or "vs Auth.js"
- User needs help choosing auth framework
- User wants feature comparison, migration advice, or cost analysis
Load `references/migration-guide-1.4.0.md` when:
- User upgrading from better-auth <1.4.0 to 1.4.0+
- User encounters
forgetPassworderrors or ESM issues - User asks about breaking changes or migration steps
Load `references/v1.4-features.md` when:
- User asks about background tasks or deferred email sending
- User needs Patreon, Vercel, or Kick OAuth provider setup
- User asks about the better-auth CLI tool
- User needs admin role permissions configuration
Load `references/nextjs/README.md` when:
- User building Next.js app with PostgreSQL (not Cloudflare D1)
- User needs organizations and 2FA example
- User asks about Next.js-specific implementation
Load `references/nextjs/postgres-example.ts` when:
- User needs complete Next.js API route implementation
- User wants to see organizations + 2FA in practice
- User asks for PostgreSQL setup with Drizzle
Framework-Specific Setup
Load `references/frameworks/nextjs.md` when:
- User building with Next.js (App Router or Pages Router)
- User needs middleware, Server Components, or API routes
Load `references/frameworks/nuxt.md` when:
- User building with Nuxt 3
- User needs H3 handlers, composables, or server routes
Load `references/frameworks/remix.md` when:
- User building with Remix
- User needs loader/action patterns or session handling
Load `references/frameworks/sveltekit.md` when:
- User building with SvelteKit
- User needs hooks, load functions, or stores
Load `references/frameworks/api-frameworks.md` when:
- User building with Express, Fastify, NestJS, or Hono (non-Cloudflare)
- User needs middleware or route configuration
Load `references/frameworks/expo-mobile.md` when:
- User building React Native or Expo app
- User needs SecureStore, deep linking, or mobile auth
Database Adapters
Load `references/databases/postgresql.md` when:
- User using PostgreSQL with Drizzle or Prisma
- User needs Neon, Supabase, or connection pooling setup
Load `references/databases/mongodb.md` when:
- User using MongoDB
- User needs Atlas setup or indexes
Load `references/databases/mysql.md` when:
- User using MySQL or PlanetScale
- User needs Vitess compatibility guidance
Plugin Guides
Load `references/plugins/authentication.md` when:
- User needs 2FA, passkeys (incl. pre-auth), magic links, email OTP, or anonymous users
- User asks about enhanced authentication methods
Load `references/plugins/enterprise.md` when:
- User needs organizations, SSO/SAML, SCIM, or admin dashboard
- User building multi-tenant or enterprise application
Load `references/plugins/api-tokens.md` when:
- User needs API keys (incl. org-owned, multi-config), bearer tokens, JWT
- User building API authentication for third parties
Load `references/plugins/payments.md` when:
- User needs Stripe (incl. seat-based billing) or Polar integration
- User building subscription or payment features
Load `references/plugins/sso.md` when:
- User needs production SSO with OIDC, OAuth2, or SAML 2.0
- User asks about OIDC discovery, SAML SLO, domain verification
- User needs organization provisioning via SSO
Load `references/plugins/test-utils.md` when:
- User writing integration or E2E tests
- User needs test factories, OTP capture, or authenticated sessions
Integration Guides
Load `references/integrations/electron.md` when:
- User building Electron desktop app with Better Auth
- User needs system browser OAuth, deep links, IPC bridges
---
Configuration Reference
Quick Config (ESM-only in v1.4.0+):
export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: "sqlite" }),
});Load `references/configuration-guide.md` for:
- Production configuration with email/password and social providers
- wrangler.toml setup and environment variables
- Session configuration, CORS setup, and ESM requirements
- Rate limiting, API keys (v1.4.0+), and troubleshooting
---
Using Bundled Resources
References (references/)
- setup-guide.md - Complete 8-step setup (D1 → Drizzle → Deploy)
- error-catalog.md - All 15 errors with solutions and prevention checklist
- advanced-features.md - 2FA, organizations, rate limiting, passkeys, magic links, migrations
- configuration-guide.md - Production config, dynamic base URL, secret rotation, D1 native
- framework-comparison.md - better-auth vs Clerk vs Auth.js, migration paths, TCO
- migration-guide-1.4.0.md - Upgrading from v1.3.x to v1.4.0+ (ESM, API changes)
- migration-guide-1.5.0.md - Upgrading from v1.4.x to v1.5.0+ (API Key, adapter imports, hooks)
- v1.4-features.md - Background tasks, new OAuth providers, SAML/SSO, CLI
- v1.5-features.md - New CLI, OAuth 2.1 Provider, Electron, i18n, D1 native, secret rotation
- v1.6-features.md - OpenTelemetry, passkey pre-auth, non-blocking scrypt
- cloudflare-worker-drizzle.ts - Complete Worker with Drizzle auth
- cloudflare-worker-kysely.ts - Complete Worker with Kysely auth
- database-schema.ts - Complete better-auth Drizzle schema
- react-client-hooks.tsx - React components with auth hooks
Framework References (references/frameworks/)
- nextjs.md - Next.js App/Pages Router integration
- nuxt.md - Nuxt 3 with H3 and composables
- remix.md - Remix loaders, actions, sessions
- sveltekit.md - SvelteKit hooks and stores
- api-frameworks.md - Express, Fastify, NestJS, Hono
- expo-mobile.md - React Native and Expo
Database References (references/databases/)
- postgresql.md - PostgreSQL with Drizzle/Prisma, Neon/Supabase
- mongodb.md - MongoDB adapter and Atlas
- mysql.md - MySQL and PlanetScale
Plugin References (references/plugins/)
- authentication.md - 2FA, passkeys (incl. pre-auth), magic links, email OTP, anonymous
- enterprise.md - Organizations, SSO, SCIM, admin
- api-tokens.md - API keys (incl. org-owned, multi-config), bearer tokens, JWT
- payments.md - Stripe, Polar integrations
- sso.md - Production SSO: OIDC discovery, SAML SLO, domain verification, security
- test-utils.md - Testing helpers: factories, OTP capture, login, Vitest/Playwright
Integration References (references/integrations/)
- electron.md - Electron desktop auth: system browser OAuth, IPC bridges, deep links
Next.js Examples (references/nextjs/)
- README.md - Next.js + PostgreSQL setup guide (not D1)
- postgres-example.ts - Complete API route with organizations, 2FA, email verification
Client Integration
Create auth client (src/lib/auth-client.ts):
import { createAuthClient } from "better-auth/client";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8787",
});Use in React:
import { authClient } from "@/lib/auth-client";
export function UserProfile() {
const { data: session, isPending } = authClient.useSession();
if (isPending) return <div>Loading...</div>;
if (!session) return <div>Not authenticated</div>;
return (
<div>
<p>Welcome, {session.user.email}</p>
<button onClick={() => authClient.signOut()}>Sign Out</button>
</div>
);
}---
Dependencies
Required:
better-auth@^1.6.0- Core authentication framework (ESM-only)
Choose ONE adapter (optional with D1 native in v1.5+):
drizzle-orm@^0.44.7+drizzle-kit@^0.31.7(recommended for complex schemas)kysely@^0.28.8+@noxharmonium/kysely-d1@^0.4.0(alternative)@better-auth/drizzle-adapter+better-auth/minimal(smallest bundle, v1.5+)
Optional:
@cloudflare/workers-types- TypeScript types for Workershono@^4.0.0- Web framework for routing@better-auth/passkey- Passkey/WebAuthn plugin@better-auth/api-key- API key auth with org support@better-auth/sso- SSO/SAML/OIDC production plugin@better-auth/electron- Electron desktop auth@better-auth/i18n- Error message translations@better-auth/oauth-provider- OAuth 2.1 authorization server
---
Beyond Cloudflare D1
This skill focuses on Cloudflare Workers + D1. better-auth also supports:
Frameworks (18 total): Next.js, Nuxt, Remix, SvelteKit, Astro, Express, NestJS, Fastify, Elysia, Expo, and more.
Databases (9 adapters): PostgreSQL, MongoDB, MySQL, Prisma, MS SQL, and others.
Additional Plugins: Anonymous auth, Email OTP, JWT, Multi-Session, OAuth 2.1 Provider, Test Utils, SCIM, payment integrations (Stripe, Polar), Device Authorization.
For non-Cloudflare setups, load the appropriate framework or database reference file, or consult the official docs: https://better-auth.com/docs
---
Official Documentation
- better-auth Docs: https://better-auth.com
- GitHub: https://github.com/better-auth/better-auth (22.4k ⭐)
- Examples: https://github.com/better-auth/better-auth/tree/main/examples
- Drizzle Docs: https://orm.drizzle.team/docs/get-started-sqlite
- Kysely Docs: https://kysely.dev/
- Discord: https://discord.gg/better-auth
---
Framework Comparison
Load `references/framework-comparison.md` for:
- Complete feature comparison: better-auth vs Clerk vs Auth.js
- v1.4.0+ new features (database joins, stateless sessions, API keys)
- Migration paths, cost analysis, and performance benchmarks
- Recommendations by use case and 5-year TCO
---
Production Examples
Verified working repositories (all use Drizzle or Kysely):
1. zwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle 2. matthewlynch/better-auth-react-router-cloudflare-d1 - Kysely 3. foxlau/react-router-v7-better-auth - Drizzle 4. zpg6/better-auth-cloudflare - Drizzle (includes CLI)
Note: Check each repo's better-auth version. Repos on v1.3.x need v1.4.0+ migration (see references/migration-guide-1.4.0.md). None use a direct d1Adapter - all require Drizzle/Kysely.
---
Secure Installation
When installing authentication packages, follow supply chain security best practices — auth libraries are high-value targets for supply chain attacks:
- Block post-install scripts —
npm config set ignore-scripts true(or Bun: disabled by default) - Cooldown period — Wait 7 days for new package versions to be vetted by the community
- Audit before installing — Run
socket package score npm <pkg>or usesocket npm install <pkg>to check packages
Load the dependency-upgrade skill for full security configuration including Socket CLI integration, cooldown setup, lockfile validation, and CI enforcement.
Complete Setup Checklist
- [ ] Verified ESM support (
"type": "module"in package.json) - v1.4.0+ required - [ ] Installed better-auth@1.6.0+ (D1 native) or + Drizzle/Kysely
- [ ] Created D1 database with wrangler
- [ ] Defined database schema (or using D1 native without schema)
- [ ] Generated and applied migrations to D1
- [ ] Set BETTER_AUTH_SECRET environment variable
- [ ] Configured baseURL in auth config (or dynamic base URL for previews)
- [ ] Enabled authentication methods (emailAndPassword, socialProviders)
- [ ] Configured CORS with credentials: true
- [ ] Set OAuth callback URLs in provider settings
- [ ] Tested auth routes (/api/auth/*)
- [ ] Tested sign-in, sign-up, session verification
- [ ] Using requestPasswordReset (not forgetPassword) - v1.4.0+ API
- [ ] Using
npx authCLI (not@better-auth/cli) - v1.5.0+ - [ ] Using
@better-auth/api-key(notbetter-auth/plugins) for API keys - v1.5.0+ - [ ] Deployed to Cloudflare Workers
---
Questions? Issues?
1. Check references/error-catalog.md for all 15 errors and solutions 2. Review references/setup-guide.md for complete 8-step setup 3. See references/advanced-features.md for 2FA, organizations, and more 4. Check official docs: https://better-auth.com 5. Ensure you're using Drizzle or Kysely (not non-existent d1Adapter)
better-auth Authentication Flow Diagrams
Visual representations of common authentication flows using better-auth.
---
1. Email/Password Sign-Up Flow
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Client │ │ Worker │ │ D1 │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
│ POST /api/auth/signup │ │
│ { email, password } │ │
├──────────────────────────>│ │
│ │ Hash password (bcrypt) │
│ │ │
│ │ INSERT INTO users │
│ ├──────────────────────────>│
│ │ │
│ │ Generate verification │
│ │ token │
│ │ │
│ │ INSERT INTO │
│ │ verification_tokens │
│ ├──────────────────────────>│
│ │ │
│ │ Send verification email │
│ │ (via email service) │
│ │ │
│ { success: true } │ │
│<──────────────────────────┤ │
│ │ │
│ │ │
│ User clicks email link │ │
│ │ │
│ GET /api/auth/verify? │ │
│ token=xyz │ │
├──────────────────────────>│ │
│ │ Verify token │
│ ├──────────────────────────>│
│ │ │
│ │ UPDATE users SET │
│ │ email_verified = true │
│ ├──────────────────────────>│
│ │ │
│ Redirect to dashboard │ │
│<──────────────────────────┤ │
│ │ │---
2. Social Sign-In Flow (Google OAuth)
┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐
│ Client │ │ Worker │ │ D1 │ │ Google │
└────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘
│ │ │ │
│ Click "Sign │ │ │
│ in with │ │ │
│ Google" │ │ │
│ │ │ │
│ POST /api/ │ │ │
│ auth/signin/ │ │ │
│ google │ │ │
├────────────────>│ │ │
│ │ Generate OAuth │ │
│ │ state + PKCE │ │
│ │ │ │
│ Redirect to │ │ │
│ Google OAuth │ │ │
│<────────────────┤ │ │
│ │ │ │
│ │ │ │
│ User authorizes on Google │ │
├───────────────────────────────────────────────────────>│
│ │ │ │
│ │ │ User approves │
│<───────────────────────────────────────────────────────┤
│ │ │ │
│ Redirect to │ │ │
│ callback with │ │ │
│ code │ │ │
│ │ │ │
│ GET /api/auth/ │ │ │
│ callback/ │ │ │
│ google?code= │ │ │
├────────────────>│ │ │
│ │ Exchange code │ │
│ │ for tokens │ │
│ ├─────────────────────────────────────>│
│ │ │ │
│ │ { access_token, │ │
│ │ id_token } │ │
│ │<─────────────────────────────────────┤
│ │ │ │
│ │ Fetch user info │ │
│ ├─────────────────────────────────────>│
│ │ │ │
│ │ { email, name, │ │
│ │ picture } │ │
│ │<─────────────────────────────────────┤
│ │ │ │
│ │ Find or create │ │
│ │ user │ │
│ ├─────────────────>│ │
│ │ │ │
│ │ Store account │ │
│ │ (provider data) │ │
│ ├─────────────────>│ │
│ │ │ │
│ │ Create session │ │
│ ├─────────────────>│ │
│ │ │ │
│ Set session │ │ │
│ cookie + │ │ │
│ redirect │ │ │
│<────────────────┤ │ │
│ │ │ │---
3. Session Verification Flow
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Client │ │ Worker │ │ KV │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
│ GET /api/protected │ │
│ Cookie: session=xyz │ │
├───────────────────────>│ │
│ │ Extract session ID │
│ │ from cookie │
│ │ │
│ │ GET session from KV │
│ ├───────────────────────>│
│ │ │
│ │ { userId, expiresAt } │
│ │<───────────────────────┤
│ │ │
│ │ Check expiration │
│ │ │
│ If valid: │ │
│ { data: ... } │ │
│<───────────────────────┤ │
│ │ │
│ If invalid: │ │
│ 401 Unauthorized │ │
│<───────────────────────┤ │
│ │ │---
4. Password Reset Flow
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Client │ │ Worker │ │ D1 │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
│ POST /api/auth/ │ │
│ forgot-password │ │
│ { email } │ │
├───────────────────────>│ │
│ │ Find user by email │
│ ├───────────────────────>│
│ │ │
│ │ Generate reset token │
│ │ │
│ │ INSERT INTO │
│ │ verification_tokens │
│ ├───────────────────────>│
│ │ │
│ │ Send reset email │
│ │ │
│ { success: true } │ │
│<───────────────────────┤ │
│ │ │
│ │ │
│ User clicks email │ │
│ link │ │
│ │ │
│ GET /reset-password? │ │
│ token=xyz │ │
├───────────────────────>│ │
│ │ Verify token │
│ ├───────────────────────>│
│ │ │
│ Show reset form │ │
│<───────────────────────┤ │
│ │ │
│ POST /api/auth/ │ │
│ reset-password │ │
│ { token, password } │ │
├───────────────────────>│ │
│ │ Hash new password │
│ │ │
│ │ UPDATE users │
│ ├───────────────────────>│
│ │ │
│ │ DELETE token │
│ ├───────────────────────>│
│ │ │
│ Redirect to login │ │
│<───────────────────────┤ │
│ │ │---
5. Two-Factor Authentication (2FA) Flow
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Client │ │ Worker │ │ D1 │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
│ POST /api/auth/ │ │
│ signin │ │
│ { email, password } │ │
├───────────────────────>│ │
│ │ Verify credentials │
│ ├───────────────────────>│
│ │ │
│ │ Check if 2FA enabled │
│ ├───────────────────────>│
│ │ │
│ { requires2FA: true } │ │
│<───────────────────────┤ │
│ │ │
│ Show 2FA input │ │
│ │ │
│ POST /api/auth/ │ │
│ verify-2fa │ │
│ { code: "123456" } │ │
├───────────────────────>│ │
│ │ Get 2FA secret │
│ ├───────────────────────>│
│ │ │
│ │ Verify TOTP code │
│ │ │
│ If valid: │ │
│ Create session │ │
│ + redirect │ │
│<───────────────────────┤ │
│ │ │---
6. Organization/Team Flow
┌─────────┐ ┌─────────┐ ┌──────────┐
│ Client │ │ Worker │ │ D1 │
└────┬────┘ └────┬────┘ └────┬─────┘
│ │ │
│ POST /api/org/create │ │
│ { name, slug } │ │
├───────────────────────>│ │
│ │ Verify session │
│ │ │
│ │ INSERT INTO orgs │
│ ├───────────────────────>│
│ │ │
│ │ INSERT INTO │
│ │ org_members │
│ │ (user as owner) │
│ ├───────────────────────>│
│ │ │
│ { org: { ... } } │ │
│<───────────────────────┤ │
│ │ │
│ │ │
│ POST /api/org/invite │ │
│ { orgId, email, │ │
│ role } │ │
├───────────────────────>│ │
│ │ Check permissions │
│ ├───────────────────────>│
│ │ │
│ │ Generate invite token │
│ │ │
│ │ INSERT INTO │
│ │ org_invitations │
│ ├───────────────────────>│
│ │ │
│ │ Send invite email │
│ │ │
│ { success: true } │ │
│<───────────────────────┤ │
│ │ │---
Database Schema Overview
┌──────────────────────┐
│ users │
├──────────────────────┤
│ id (PK) │
│ email (UNIQUE) │
│ email_verified │
│ name │
│ image │
│ role │
│ created_at │
│ updated_at │
└──────────┬───────────┘
│
│ 1:N
│
┌──────────┴───────────┐ ┌──────────────────────┐
│ sessions │ │ accounts │
├──────────────────────┤ ├──────────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) │◄───────┤ user_id (FK) │
│ expires_at │ │ provider │
│ ip_address │ │ provider_account_id │
│ user_agent │ │ access_token │
│ created_at │ │ refresh_token │
└──────────────────────┘ │ expires_at │
│ created_at │
└──────────────────────┘
┌──────────────────────┐
│ verification_tokens │
├──────────────────────┤
│ identifier │
│ token │
│ expires │
│ created_at │
└──────────────────────┘
┌──────────────────────┐ ┌──────────────────────┐
│ organizations │ │ organization_members │
├──────────────────────┤ ├──────────────────────┤
│ id (PK) │ │ id (PK) │
│ name │ │ organization_id (FK) │◄──┐
│ slug (UNIQUE) │◄───────┤ user_id (FK) │ │
│ logo │ │ role │ │
│ created_at │ │ created_at │ │
│ updated_at │ └──────────────────────┘ │
└──────────────────────┘ │
│
┌──────────────────────┐ │
│organization_invites │ │
├──────────────────────┤ │
│ id (PK) │ │
│ organization_id (FK) │────────────────────────────────────┘
│ email │
│ role │
│ invited_by (FK) │
│ token │
│ expires_at │
│ created_at │
└──────────────────────┘---
These diagrams illustrate the complete authentication flows supported by better-auth. Use them as reference when implementing auth in your application.
better-auth Advanced Features
Deep dive into 2FA, organizations, rate limiting, and migration guides.
---
Two-Factor Authentication (2FA)
Add TOTP (Time-based One-Time Password) or SMS-based 2FA to your application.
Server Setup
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
database: /* ... */,
plugins: [
twoFactor({
methods: ["totp", "sms"],
issuer: "MyApp",
}),
],
});Client Usage
Enable 2FA:
// Generate QR code for authenticator app
const { data, error } = await authClient.twoFactor.enable({
method: "totp",
});
if (data) {
// Show QR code to user: data.qrCode
// Or show secret: data.secret
console.log("Scan this QR code:", data.qrCode);
}Verify Setup Code:
// User enters code from authenticator app
await authClient.twoFactor.verifySetup({
code: "123456",
});Sign In with 2FA:
// Step 1: Sign in with email/password
const { data: session, error } = await authClient.signIn.email({
email: "user@example.com",
password: "password123",
});
// Step 2: If 2FA enabled, verify code
if (session?.user.twoFactorEnabled) {
await authClient.twoFactor.verify({
code: "123456",
});
}Disable 2FA:
await authClient.twoFactor.disable({
password: "user-password", // Confirm with password
});Backup Codes
Generate backup codes for account recovery:
// Generate backup codes
const { data: codes } = await authClient.twoFactor.generateBackupCodes();
// codes: ["ABC123", "DEF456", "GHI789", ...]
// Show these to user ONCE and tell them to save them
// Use backup code
await authClient.twoFactor.verifyBackupCode({
code: "ABC123",
});---
Organizations & Teams
Multi-tenant SaaS with organizations, teams, and role-based permissions.
Server Setup
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
export const auth = betterAuth({
database: /* ... */,
plugins: [
organization({
roles: ["owner", "admin", "member"],
permissions: {
owner: ["read", "write", "delete", "manage_members", "manage_billing"],
admin: ["read", "write", "delete", "manage_members"],
member: ["read", "write"],
},
}),
],
});Client Usage
Create Organization:
await authClient.organization.create({
name: "Acme Corp",
slug: "acme", // Unique slug for URLs
metadata: {
industry: "Technology",
size: "10-50",
},
});List User Organizations:
const { data: orgs } = await authClient.organization.list();
// orgs: [{ id, name, slug, role, ... }, ...]Switch Active Organization:
await authClient.organization.setActive({
organizationId: "org_123",
});Invite Member:
await authClient.organization.inviteMember({
organizationId: "org_123",
email: "newuser@example.com",
role: "member",
});Update Member Role:
await authClient.organization.updateMemberRole({
organizationId: "org_123",
userId: "user_456",
role: "admin",
});Remove Member:
await authClient.organization.removeMember({
organizationId: "org_123",
userId: "user_456",
});Check Permissions:
const canDelete = await authClient.organization.hasPermission({
organizationId: "org_123",
permission: "delete",
});
if (canDelete) {
// Show delete button
}Accept Invitation:
await authClient.organization.acceptInvitation({
invitationId: "inv_789",
});Server-Side Permission Checks
// In your API route
app.delete("/api/projects/:id", async (c) => {
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
// Check permission
const canDelete = await auth.api.organization.hasPermission({
userId: session.user.id,
organizationId: session.activeOrganizationId,
permission: "delete",
});
if (!canDelete) {
return c.json({ error: "Forbidden" }, 403);
}
// Delete project
// ...
});---
Rate Limiting with KV
Protect your auth endpoints from brute-force attacks.
Server Setup
import { betterAuth } from "better-auth";
import { rateLimit } from "better-auth/plugins";
type Env = {
DB: D1Database;
RATE_LIMIT_KV: KVNamespace;
// ...
};
export function createAuth(db: Database, env: Env) {
return betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
plugins: [
rateLimit({
window: 60, // 60 seconds
max: 10, // 10 requests per window
storage: {
get: async (key) => {
return await env.RATE_LIMIT_KV.get(key);
},
set: async (key, value, ttl) => {
await env.RATE_LIMIT_KV.put(key, value, {
expirationTtl: ttl,
});
},
},
}),
],
});
}Advanced Rate Limiting
Different limits for different endpoints:
rateLimit({
rules: [
{
path: "/api/auth/sign-in",
window: 60,
max: 5, // 5 login attempts per minute
},
{
path: "/api/auth/sign-up",
window: 3600,
max: 3, // 3 sign-ups per hour
},
{
path: "/api/auth/reset-password",
window: 3600,
max: 3, // 3 password resets per hour
},
],
storage: {
get: async (key) => await env.RATE_LIMIT_KV.get(key),
set: async (key, value, ttl) => {
await env.RATE_LIMIT_KV.put(key, value, { expirationTtl: ttl });
},
},
});---
Passkeys (WebAuthn)
Passwordless authentication using biometric or hardware keys.
Server Setup
import { betterAuth } from "better-auth";
import { passkey } from "better-auth/plugins";
export const auth = betterAuth({
database: /* ... */,
plugins: [
passkey({
rpName: "MyApp",
rpID: "yourdomain.com", // Your domain
}),
],
});Client Usage
Register Passkey:
// User must be authenticated first
const { data, error } = await authClient.passkey.register({
name: "MacBook Touch ID", // User-friendly name
});
if (data) {
console.log("Passkey registered successfully");
}Sign In with Passkey:
const { data: session, error } = await authClient.passkey.signIn();
if (session) {
console.log("Signed in with passkey:", session.user);
}List User's Passkeys:
const { data: passkeys } = await authClient.passkey.list();
// passkeys: [{ id, name, createdAt, lastUsed }, ...]Remove Passkey:
await authClient.passkey.remove({
passkeyId: "pk_123",
});---
Magic Links
Passwordless authentication via email links.
Server Setup
export const auth = betterAuth({
database: /* ... */,
magicLink: {
enabled: true,
expiresIn: 60 * 10, // 10 minutes
sendMagicLink: async ({ email, url, token }) => {
await sendEmail({
to: email,
subject: "Sign in to MyApp",
html: `
<p>Click the link below to sign in:</p>
<a href="${url}">Sign In</a>
<p>Or use this code: ${token}</p>
<p>This link expires in 10 minutes.</p>
`,
});
},
},
});Client Usage
// Request magic link
await authClient.magicLink.request({
email: "user@example.com",
callbackURL: "/dashboard",
});
// User clicks link in email, gets redirected to callbackURL with token
// better-auth automatically verifies token and creates session---
Session Management Best Practices
Custom Session Data
Store additional data in session:
export const auth = betterAuth({
database: /* ... */,
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update every 24 hours
// Add custom session data
onSession: async ({ session, user }) => {
return {
...session,
activeOrganizationId: user.activeOrganizationId,
permissions: await getPermissions(user.id),
};
},
},
});Session Device Tracking
Track user devices and active sessions:
// List active sessions
const { data: sessions } = await authClient.session.list();
// sessions: [
// { id, device: "Chrome on MacOS", ipAddress: "192.168.1.1", lastActive: ... },
// { id, device: "Safari on iPhone", ipAddress: "192.168.1.2", lastActive: ... },
// ]
// Revoke specific session
await authClient.session.revoke({
sessionId: "ses_123",
});
// Revoke all other sessions (keep current)
await authClient.session.revokeOthers();---
Migration Guides
From Clerk
Key differences:
- Clerk: Third-party service → better-auth: Self-hosted
- Clerk: Proprietary → better-auth: Open source
- Clerk: Monthly cost → better-auth: Free
Migration steps:
1. Export user data from Clerk (CSV or API) 2. Import into better-auth database:
// migration script
const clerkUsers = await fetchClerkUsers();
for (const clerkUser of clerkUsers) {
await db.insert(user).values({
id: clerkUser.id,
email: clerkUser.email,
emailVerified: clerkUser.email_verified,
name: clerkUser.first_name + " " + clerkUser.last_name,
image: clerkUser.profile_image_url,
});
}3. Replace Clerk SDK with better-auth client:
// Before (Clerk)
import { useUser } from "@clerk/nextjs";
const { user } = useUser();
// After (better-auth)
import { authClient } from "@/lib/auth-client";
const { data: session } = authClient.useSession();
const user = session?.user;4. Update middleware for session verification 5. Configure social providers (same OAuth apps, different config)
---
From Auth.js (NextAuth)
Key differences:
- Auth.js: Limited features → better-auth: Comprehensive (2FA, orgs, etc.)
- Auth.js: Callbacks-heavy → better-auth: Plugin-based
- Auth.js: Session handling varies → better-auth: Consistent
Migration steps:
1. Database schema: Auth.js and better-auth use similar schemas, but column names differ 2. Replace configuration:
// Before (Auth.js)
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";
export default NextAuth({
providers: [GoogleProvider({ /* ... */ })],
});
// After (better-auth)
import { betterAuth } from "better-auth";
export const auth = betterAuth({
socialProviders: {
google: { /* ... */ },
},
});3. Update client hooks:
// Before
import { useSession } from "next-auth/react";
// After
import { authClient } from "@/lib/auth-client";
const { data: session } = authClient.useSession();---
From Auth0
For detailed Auth0 migration instructions: https://better-auth.com/docs/guides/migrations/auth0
Key points:
- Export users via Auth0 Management API
- Map Auth0 user metadata to better-auth fields
- Recreate social connections as better-auth providers
- Update application callback URLs
---
From Supabase Auth
For detailed Supabase Auth migration instructions: https://better-auth.com/docs/guides/migrations/supabase
Key points:
- Export users from Supabase auth.users table
- Migrate to better-auth schema (user, session, account)
- Update client from @supabase/auth-helpers to better-auth client
- Reconfigure OAuth providers
---
From WorkOS
For detailed WorkOS migration instructions: https://better-auth.com/docs/guides/migrations/workos
Key points:
- Export organization and user data
- Map WorkOS organizations to better-auth organizations plugin
- Recreate SSO connections as better-auth SSO providers
- Update directory sync to SCIM plugin
---
Security Best Practices
1. Always use HTTPS in production
const auth = betterAuth({
baseURL: process.env.NODE_ENV === "production"
? "https://yourdomain.com"
: "http://localhost:3000",
// ...
});2. Rotate secrets regularly
openssl rand -base64 32
wrangler secret put BETTER_AUTH_SECRET3. Validate email domains for sign-up
emailAndPassword: {
enabled: true,
validate: async (email) => {
const blockedDomains = ["tempmail.com", "guerrillamail.com"];
const domain = email.split("@")[1];
if (blockedDomains.includes(domain)) {
throw new Error("Email domain not allowed");
}
},
}4. Enable rate limiting for auth endpoints
See "Rate Limiting with KV" section above.
5. Log auth events for security monitoring
export const auth = betterAuth({
database: /* ... */,
hooks: {
after: {
signIn: async ({ user, session }) => {
await logAuthEvent({
type: "sign_in",
userId: user.id,
ip: session.ipAddress,
userAgent: session.userAgent,
timestamp: new Date(),
});
},
signUp: async ({ user }) => {
await logAuthEvent({
type: "sign_up",
userId: user.id,
timestamp: new Date(),
});
},
},
},
});---
Performance Optimization
1. Cache session lookups (use KV for Workers)
See "D1 Eventual Consistency Issues" in error-catalog.md
2. Use indexes on frequently queried fields
CREATE INDEX idx_sessions_user_id ON session(userId);
CREATE INDEX idx_accounts_provider ON account(providerId, accountId);
CREATE INDEX idx_sessions_token ON session(token);3. Minimize session data (only essential fields)
session: {
onSession: async ({ session, user }) => {
// Only include what you need
return {
user: {
id: user.id,
email: user.email,
name: user.name,
// Don't include everything
},
};
},
}---
Development Workflow
Environment-Specific Configs
const isDev = process.env.NODE_ENV === "development";
export const auth = betterAuth({
baseURL: isDev ? "http://localhost:3000" : "https://yourdomain.com",
session: {
expiresIn: isDev
? 60 * 60 * 24 * 365 // 1 year for dev
: 60 * 60 * 24 * 7, // 7 days for prod
},
});Test Social Auth Locally with ngrok
ngrok http 3000
# Use ngrok URL as redirect URI in OAuth provider---
Official Resources:
- 2FA Plugin: https://better-auth.com/docs/plugins/two-factor
- Organizations: https://better-auth.com/docs/plugins/organization
- Passkeys: https://better-auth.com/docs/plugins/passkey
- Magic Links: https://better-auth.com/docs/authentication/magic-link
- Rate Limiting: https://better-auth.com/docs/plugins/rate-limit
/**
* Complete Cloudflare Worker with better-auth + Drizzle ORM
*
* This example demonstrates:
* - D1 database with Drizzle ORM adapter
* - Email/password authentication
* - Google and GitHub OAuth
* - Protected routes with session verification
* - CORS configuration for SPA
* - KV storage for sessions (strong consistency)
* - Rate limiting with KV
*
* ⚠️ CRITICAL: better-auth requires Drizzle ORM or Kysely for D1
* There is NO direct d1Adapter()!
*/
import { Hono } from "hono";
import { cors } from "hono/cors";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
import { rateLimit } from "better-auth/plugins";
import * as schema from "../db/schema"; // Your Drizzle schema
// ═══════════════════════════════════════════════════════════════
// Environment bindings
// ═══════════════════════════════════════════════════════════════
type Env = {
DB: D1Database;
SESSIONS_KV: KVNamespace;
RATE_LIMIT_KV: KVNamespace;
BETTER_AUTH_SECRET: string;
BETTER_AUTH_URL: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
FRONTEND_URL: string;
};
// Database type
export type Database = DrizzleD1Database<typeof schema>;
const app = new Hono<{ Bindings: Env }>();
// ═══════════════════════════════════════════════════════════════
// CORS configuration for SPA
// ═══════════════════════════════════════════════════════════════
app.use("/api/*", async (c, next) => {
const corsMiddleware = cors({
origin: [c.env.FRONTEND_URL, "http://localhost:3000"],
credentials: true,
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
});
return corsMiddleware(c, next);
});
// ═══════════════════════════════════════════════════════════════
// Helper: Initialize Drizzle database
// ═══════════════════════════════════════════════════════════════
function createDatabase(d1: D1Database): Database {
return drizzle(d1, { schema });
}
// ═══════════════════════════════════════════════════════════════
// Helper: Initialize auth (per-request to access env)
// ═══════════════════════════════════════════════════════════════
function createAuth(db: Database, env: Env) {
return betterAuth({
// Base URL for OAuth callbacks
baseURL: env.BETTER_AUTH_URL,
// Secret for signing tokens
secret: env.BETTER_AUTH_SECRET,
// ⚠️ CRITICAL: Use Drizzle adapter with SQLite provider
// There is NO direct d1Adapter()!
database: drizzleAdapter(db, {
provider: "sqlite",
}),
// Email/password authentication
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendVerificationEmail: async ({ user, url, token }) => {
// TODO: Implement email sending
// Use Resend, SendGrid, or Cloudflare Email Routing
console.log(`Verification email for ${user.email}: ${url}`);
console.log(`Verification code: ${token}`);
},
},
// Social providers
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"],
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["user:email", "read:user"],
},
},
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update every 24 hours
// Use KV for sessions (strong consistency vs D1 eventual consistency)
storage: {
get: async (sessionId) => {
const session = await env.SESSIONS_KV.get(sessionId);
return session ? JSON.parse(session) : null;
},
set: async (sessionId, session, ttl) => {
await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), {
expirationTtl: ttl,
});
},
delete: async (sessionId) => {
await env.SESSIONS_KV.delete(sessionId);
},
},
},
// Plugins
plugins: [
rateLimit({
window: 60, // 60 seconds
max: 10, // 10 requests per window
storage: {
get: async (key) => {
return await env.RATE_LIMIT_KV.get(key);
},
set: async (key, value, ttl) => {
await env.RATE_LIMIT_KV.put(key, value, {
expirationTtl: ttl,
});
},
},
}),
],
});
}
// ═══════════════════════════════════════════════════════════════
// Auth routes - handle all better-auth endpoints
// ═══════════════════════════════════════════════════════════════
app.all("/api/auth/*", async (c) => {
const db = createDatabase(c.env.DB);
const auth = createAuth(db, c.env);
return auth.handler(c.req.raw);
});
// ═══════════════════════════════════════════════════════════════
// Example: Protected API route
// ═══════════════════════════════════════════════════════════════
app.get("/api/protected", async (c) => {
const db = createDatabase(c.env.DB);
const auth = createAuth(db, c.env);
// Verify session
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
return c.json({
message: "Protected data",
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
},
});
});
// ═══════════════════════════════════════════════════════════════
// Example: User profile endpoint
// ═══════════════════════════════════════════════════════════════
app.get("/api/user/profile", async (c) => {
const db = createDatabase(c.env.DB);
const auth = createAuth(db, c.env);
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
// Fetch additional user data from D1
const userProfile = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, session.user.id),
});
return c.json(userProfile);
});
// ═══════════════════════════════════════════════════════════════
// Example: Update user profile
// ═══════════════════════════════════════════════════════════════
app.patch("/api/user/profile", async (c) => {
const db = createDatabase(c.env.DB);
const auth = createAuth(db, c.env);
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
const { name } = await c.req.json();
// Update user in D1 using Drizzle
await db
.update(schema.user)
.set({ name, updatedAt: new Date() })
.where(eq(schema.user.id, session.user.id));
return c.json({ success: true });
});
// ═══════════════════════════════════════════════════════════════
// Example: Admin-only endpoint
// ═══════════════════════════════════════════════════════════════
app.get("/api/admin/users", async (c) => {
const db = createDatabase(c.env.DB);
const auth = createAuth(db, c.env);
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
// Check admin role (you'd store this in users table)
const user = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, session.user.id),
// Add role field to your schema if needed
});
// if (user.role !== 'admin') {
// return c.json({ error: 'Forbidden' }, 403)
// }
// Fetch all users
const users = await db.query.user.findMany({
columns: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
return c.json(users);
});
// ═══════════════════════════════════════════════════════════════
// Health check
// ═══════════════════════════════════════════════════════════════
app.get("/health", (c) => {
return c.json({
status: "ok",
timestamp: new Date().toISOString(),
});
});
// ═══════════════════════════════════════════════════════════════
// Export Worker
// ═══════════════════════════════════════════════════════════════
export default app;
/**
* ═══════════════════════════════════════════════════════════════
* SETUP CHECKLIST
* ═══════════════════════════════════════════════════════════════
*
* 1. Create D1 database:
* wrangler d1 create my-app-db
*
* 2. Create KV namespaces:
* wrangler kv:namespace create SESSIONS_KV
* wrangler kv:namespace create RATE_LIMIT_KV
*
* 3. Add to wrangler.toml:
* [[d1_databases]]
* binding = "DB"
* database_name = "my-app-db"
* database_id = "YOUR_ID"
*
* [[kv_namespaces]]
* binding = "SESSIONS_KV"
* id = "YOUR_ID"
*
* [[kv_namespaces]]
* binding = "RATE_LIMIT_KV"
* id = "YOUR_ID"
*
* [vars]
* BETTER_AUTH_URL = "http://localhost:8787"
* FRONTEND_URL = "http://localhost:3000"
*
* 4. Set secrets:
* wrangler secret put BETTER_AUTH_SECRET
* wrangler secret put GOOGLE_CLIENT_ID
* wrangler secret put GOOGLE_CLIENT_SECRET
* wrangler secret put GITHUB_CLIENT_ID
* wrangler secret put GITHUB_CLIENT_SECRET
*
* 5. Generate and apply migrations:
* npx drizzle-kit generate
* wrangler d1 migrations apply my-app-db --local
* wrangler d1 migrations apply my-app-db --remote
*
* 6. Deploy:
* wrangler deploy
*
* ═══════════════════════════════════════════════════════════════
*/
/**
* Complete Cloudflare Worker with better-auth + Kysely
*
* This example demonstrates:
* - D1 database with Kysely adapter
* - Email/password authentication
* - Google OAuth
* - Protected routes with session verification
* - CORS configuration for SPA
* - CamelCasePlugin for schema conversion
*
* ⚠️ CRITICAL: better-auth requires Kysely (or Drizzle) for D1
* There is NO direct d1Adapter()!
*/
import { Hono } from "hono";
import { cors } from "hono/cors";
import { betterAuth } from "better-auth";
import { Kysely, CamelCasePlugin } from "kysely";
import { D1Dialect } from "@noxharmonium/kysely-d1";
// ═══════════════════════════════════════════════════════════════
// Environment bindings
// ═══════════════════════════════════════════════════════════════
type Env = {
DB: D1Database;
BETTER_AUTH_SECRET: string;
BETTER_AUTH_URL: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
FRONTEND_URL: string;
};
const app = new Hono<{ Bindings: Env }>();
// ═══════════════════════════════════════════════════════════════
// CORS configuration for SPA
// ═══════════════════════════════════════════════════════════════
app.use("/api/*", async (c, next) => {
const corsMiddleware = cors({
origin: [c.env.FRONTEND_URL, "http://localhost:3000"],
credentials: true,
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
});
return corsMiddleware(c, next);
});
// ═══════════════════════════════════════════════════════════════
// Helper: Initialize auth with Kysely
// ═══════════════════════════════════════════════════════════════
function createAuth(env: Env) {
return betterAuth({
// Base URL for OAuth callbacks
baseURL: env.BETTER_AUTH_URL,
// Secret for signing tokens
secret: env.BETTER_AUTH_SECRET,
// ⚠️ CRITICAL: Use Kysely with D1Dialect
// There is NO direct d1Adapter()!
database: {
db: new Kysely({
dialect: new D1Dialect({
database: env.DB,
}),
plugins: [
// CRITICAL: CamelCasePlugin converts between snake_case (DB) and camelCase (better-auth)
// Without this, session reads will fail if your schema uses snake_case
new CamelCasePlugin(),
],
}),
type: "sqlite",
},
// Email/password authentication
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendVerificationEmail: async ({ user, url, token }) => {
// TODO: Implement email sending
console.log(`Verification email for ${user.email}: ${url}`);
console.log(`Verification code: ${token}`);
},
},
// Social providers
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"],
},
},
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update every 24 hours
},
});
}
// ═══════════════════════════════════════════════════════════════
// Auth routes - handle all better-auth endpoints
// ═══════════════════════════════════════════════════════════════
app.all("/api/auth/*", async (c) => {
const auth = createAuth(c.env);
return auth.handler(c.req.raw);
});
// ═══════════════════════════════════════════════════════════════
// Example: Protected API route
// ═══════════════════════════════════════════════════════════════
app.get("/api/protected", async (c) => {
const auth = createAuth(c.env);
// Verify session
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
return c.json({
message: "Protected data",
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
},
});
});
// ═══════════════════════════════════════════════════════════════
// Example: User profile endpoint
// ═══════════════════════════════════════════════════════════════
app.get("/api/user/profile", async (c) => {
const auth = createAuth(c.env);
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
// Fetch user data using Kysely
const db = new Kysely({
dialect: new D1Dialect({ database: c.env.DB }),
plugins: [new CamelCasePlugin()],
});
const user = await db
.selectFrom("user")
.select(["id", "email", "name", "image", "createdAt"])
.where("id", "=", session.user.id)
.executeTakeFirst();
return c.json(user);
});
// ═══════════════════════════════════════════════════════════════
// Health check
// ═══════════════════════════════════════════════════════════════
app.get("/health", (c) => {
return c.json({
status: "ok",
timestamp: new Date().toISOString(),
});
});
// ═══════════════════════════════════════════════════════════════
// Export Worker
// ═══════════════════════════════════════════════════════════════
export default app;
/**
* ═══════════════════════════════════════════════════════════════
* SETUP CHECKLIST
* ═══════════════════════════════════════════════════════════════
*
* 1. Install dependencies:
* npm install better-auth kysely @noxharmonium/kysely-d1 hono
*
* 2. Create D1 database:
* wrangler d1 create my-app-db
*
* 3. Add to wrangler.toml:
* [[d1_databases]]
* binding = "DB"
* database_name = "my-app-db"
* database_id = "YOUR_ID"
*
* [vars]
* BETTER_AUTH_URL = "http://localhost:8787"
* FRONTEND_URL = "http://localhost:3000"
*
* 4. Set secrets:
* wrangler secret put BETTER_AUTH_SECRET
* wrangler secret put GOOGLE_CLIENT_ID
* wrangler secret put GOOGLE_CLIENT_SECRET
*
* 5. Create database schema manually (Kysely doesn't auto-generate):
* wrangler d1 execute my-app-db --local --command "
* CREATE TABLE user (
* id TEXT PRIMARY KEY,
* name TEXT NOT NULL,
* email TEXT NOT NULL UNIQUE,
* email_verified INTEGER NOT NULL DEFAULT 0,
* image TEXT,
* created_at INTEGER NOT NULL DEFAULT (unixepoch()),
* updated_at INTEGER NOT NULL DEFAULT (unixepoch())
* );
* CREATE TABLE session (...);
* CREATE TABLE account (...);
* CREATE TABLE verification (...);
* "
*
* 6. Apply schema to remote:
* wrangler d1 execute my-app-db --remote --file schema.sql
*
* 7. Deploy:
* wrangler deploy
*
* ═══════════════════════════════════════════════════════════════
* WHY CamelCasePlugin?
* ═══════════════════════════════════════════════════════════════
*
* If your database schema uses snake_case (email_verified),
* but better-auth expects camelCase (emailVerified), the
* CamelCasePlugin automatically converts between the two.
*
* Without it, session reads will fail with missing fields.
*
* ═══════════════════════════════════════════════════════════════
*/
Better-Auth Configuration Guide
Last Updated: 2026-04-08 Package: better-auth@1.6.0 Requirements: ESM-only (v1.4.0+)
---
Overview
This guide provides complete configuration examples for better-auth v1.4.0+ with Cloudflare D1, Drizzle ORM, and Hono.
CRITICAL: better-auth v1.4.0+ is ESM-only. CommonJS is no longer supported.
---
ESM Requirements
package.json
{
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"better-auth": "^1.6.0",
"drizzle-orm": "^0.44.7",
"hono": "^4.0.0"
}
}Import Syntax
// ✅ CORRECT (ESM)
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
// ❌ WRONG (CommonJS - no longer works)
const { betterAuth } = require('better-auth');---
Minimal Configuration
For quick setup with email/password authentication:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./db/schema";
export function createAuth(env: {
DB: D1Database;
BETTER_AUTH_SECRET: string;
BETTER_AUTH_URL: string;
}) {
const db = drizzle(env.DB, { schema });
return betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: "sqlite" }),
});
}---
Production Configuration
Complete setup with email/password and social providers:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./db/schema";
export function createAuth(env: Env) {
const db = drizzle(env.DB, { schema });
return betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: "sqlite" }),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendVerificationEmail: async ({ user, url, token, ctx }) => {
// v1.4.0+: callback signature changed (request → ctx)
await sendEmail({
to: user.email,
subject: "Verify your email",
html: `<a href="${url}">Verify Email</a>`,
});
},
},
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"],
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["user:email", "read:user"],
},
// v1.4.3+ New: Vercel provider
vercel: {
clientId: env.VERCEL_CLIENT_ID,
clientSecret: env.VERCEL_CLIENT_SECRET,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update every 24 hours
// v1.4.0+: JWE encryption by default (cookie caching)
},
// v1.4.0+ New: Trusted proxy headers support
trustedOrigins: ["https://yourdomain.com"],
});
}---
wrangler.toml Configuration
name = "my-app"
compatibility_date = "2024-11-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-database-id"
[vars]
BETTER_AUTH_URL = "https://yourdomain.com"
# Set secrets via: wrangler secret put SECRET_NAME
# Required secrets:
# - BETTER_AUTH_SECRET (generate: openssl rand -base64 32)
# - GOOGLE_CLIENT_ID
# - GOOGLE_CLIENT_SECRET
# - GITHUB_CLIENT_ID
# - GITHUB_CLIENT_SECRET---
Environment Variables
Local Development (.dev.vars)
# DO NOT COMMIT THIS FILE
BETTER_AUTH_URL=http://localhost:8787
BETTER_AUTH_SECRET=your-secret-here-generate-with-openssl
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-secretProduction Secrets
# Set via Wrangler CLI (not in wrangler.toml)
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET---
Advanced Configuration Options
CORS Configuration
For cross-origin requests (e.g., frontend on different domain):
import { Hono } from "hono";
import { cors } from "hono/cors";
const app = new Hono<{ Bindings: Env }>();
app.use("/api/auth/*", cors({
origin: ["https://yourdomain.com", "http://localhost:3000"],
credentials: true, // CRITICAL: Required for sessions
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
}));
app.all("/api/auth/*", async (c) => {
const auth = createAuth(c.env);
return auth.handler(c.req.raw);
});Session Configuration (v1.4.0+)
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update every 24 hours
// v1.4.0+ New: Cookie caching with JWE encryption (stateless sessions)
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
strategy: "jwe", // "jwe", "jwt", or "compact" for stateless sessions
},
// Note: For stateless sessions, omit database session storage and use cookieCache strategy
}Rate Limiting Configuration
import { betterAuth } from "better-auth";
import { rateLimit } from "better-auth/plugins";
export const auth = betterAuth({
// ... other config
plugins: [
rateLimit({
window: 60 * 1000, // 1 minute
max: 10, // 10 requests per minute
}),
],
});API Key Plugin (v1.4.0+)
import { apiKey } from "better-auth/plugins";
export const auth = betterAuth({
// ... other config
plugins: [
apiKey({
// Enable API key authentication
enabled: true,
}),
],
});---
TypeScript Configuration
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"]
}
}Type Definitions
// src/types/env.d.ts
export interface Env {
DB: D1Database;
BETTER_AUTH_SECRET: string;
BETTER_AUTH_URL: string;
GOOGLE_CLIENT_ID?: string;
GOOGLE_CLIENT_SECRET?: string;
GITHUB_CLIENT_ID?: string;
GITHUB_CLIENT_SECRET?: string;
}---
Dynamic Base URL (v1.5+)
Allowlist-based multi-domain support for Vercel previews, reverse proxies, multi-domain setups.
export const auth = betterAuth({
baseURL: {
allowedHosts: [
"myapp.com",
"*.vercel.app",
"preview-*.myapp.com",
"localhost:3000",
],
fallback: "https://myapp.com",
protocol: "auto",
},
});allowedHosts are automatically added to trustedOrigins. Supports wildcard patterns (*).
---
Secret Key Rotation (v1.5+)
Non-destructive rotation without invalidating existing sessions.
export const auth = betterAuth({
secrets: [
{ version: 2, value: "new-secret-key-at-least-32-chars" },
{ version: 1, value: "old-secret-key-still-used-to-decrypt" },
],
});Or via env: BETTER_AUTH_SECRETS="2:new-secret,1:old-secret"
---
Defer Session Refresh (v1.5+)
For read-replica database setups. GET becomes read-only, returns needsRefresh: true.
export const auth = betterAuth({
session: {
deferSessionRefresh: true,
},
});---
Verification on Secondary Storage (v1.5+)
Store verification tokens in Redis instead of the database.
export const auth = betterAuth({
secondaryStorage: { /* Redis config */ },
verification: {
storeIdentifier: "hashed",
storeInDatabase: false,
},
});---
Minimal Bundle (v1.5+)
Use better-auth/minimal with extracted adapter packages for smallest bundle:
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { betterAuth } from "better-auth/minimal";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg" }),
});---
D1 Native Support (v1.5+)
Pass D1 binding directly without Drizzle/Kysely:
import { betterAuth } from "better-auth";
export default {
async fetch(request, env) {
const auth = betterAuth({
database: env.DB,
});
return auth.handler(request);
},
} satisfies ExportedHandler<{ DB: D1Database }>;---
Migration from v1.3.x
If upgrading from better-auth v1.3.x, see migration-guide-1.4.0.md for:
- ESM migration steps
- API rename changes
- Callback signature updates
- Breaking changes checklist
---
Troubleshooting
"Cannot use import statement outside a module"
Cause: Missing "type": "module" in package.json Fix: Add to package.json: { "type": "module" }
CORS Errors
Cause: Missing credentials: true in CORS config Fix: Ensure CORS middleware includes credentials: true
Session Not Persisting
Cause: Cookie domain mismatch or missing CORS credentials Fix: 1. Verify baseURL matches deployed domain 2. Ensure CORS credentials: true 3. Check cookie SameSite and Secure settings
OAuth Redirect URI Mismatch
Cause: Callback URL doesn't match provider settings Fix: Ensure exact match in provider settings:
- ✅
https://yourdomain.com/api/auth/callback/google - ❌
https://yourdomain.com/api/auth/callback/google/(trailing slash) - ❌
http://yourdomain.com/api/auth/callback/google(http vs https)
---
Official Documentation
- better-auth Docs: https://better-auth.com
- v1.4.0 Changelog: https://www.better-auth.com/blog/1-4
- Drizzle ORM: https://orm.drizzle.team
- Cloudflare D1: https://developers.cloudflare.com/d1
---
Last verified: 2026-04-08 with better-auth@1.6.0
/**
* Complete better-auth Database Schema for Drizzle ORM + D1
*
* This schema includes all tables required by better-auth core.
* You can add your own application tables below.
*
* ═══════════════════════════════════════════════════════════════
* CRITICAL NOTES
* ═══════════════════════════════════════════════════════════════
*
* 1. Column names use camelCase (emailVerified, createdAt)
* - This matches better-auth expectations
* - If you use snake_case, you MUST use CamelCasePlugin with Kysely
*
* 2. Timestamps use INTEGER with mode: "timestamp"
* - D1 (SQLite) doesn't have native timestamp type
* - Unix epoch timestamps (seconds since 1970)
*
* 3. Booleans use INTEGER with mode: "boolean"
* - D1 (SQLite) doesn't have native boolean type
* - 0 = false, 1 = true
*
* 4. Foreign keys use onDelete: "cascade"
* - Automatically delete related records
* - session deleted when user deleted
* - account deleted when user deleted
*
* ═══════════════════════════════════════════════════════════════
*/
import { integer, sqliteTable, text, index } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// ═══════════════════════════════════════════════════════════════
// better-auth CORE TABLES
// ═══════════════════════════════════════════════════════════════
/**
* Users table - stores all user accounts
*/
export const user = sqliteTable(
"user",
{
id: text().primaryKey(),
name: text().notNull(),
email: text().notNull().unique(),
emailVerified: integer({ mode: "boolean" }).notNull().default(false),
image: text(), // Profile picture URL
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
emailIdx: index("user_email_idx").on(table.email),
})
);
/**
* Sessions table - stores active user sessions
*
* NOTE: Consider using KV storage for sessions instead of D1
* to avoid eventual consistency issues
*/
export const session = sqliteTable(
"session",
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
token: text().notNull().unique(),
expiresAt: integer({ mode: "timestamp" }).notNull(),
ipAddress: text(),
userAgent: text(),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
userIdIdx: index("session_user_id_idx").on(table.userId),
tokenIdx: index("session_token_idx").on(table.token),
})
);
/**
* Accounts table - stores OAuth provider accounts and passwords
*/
export const account = sqliteTable(
"account",
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accountId: text().notNull(), // Provider's user ID
providerId: text().notNull(), // "google", "github", etc.
accessToken: text(),
refreshToken: text(),
accessTokenExpiresAt: integer({ mode: "timestamp" }),
refreshTokenExpiresAt: integer({ mode: "timestamp" }),
scope: text(), // OAuth scopes granted
idToken: text(), // OpenID Connect ID token
password: text(), // Hashed password for email/password auth
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
userIdIdx: index("account_user_id_idx").on(table.userId),
providerIdx: index("account_provider_idx").on(
table.providerId,
table.accountId
),
})
);
/**
* Verification tokens - for email verification, password reset, etc.
*/
export const verification = sqliteTable(
"verification",
{
id: text().primaryKey(),
identifier: text().notNull(), // Email or user ID
value: text().notNull(), // Token value
expiresAt: integer({ mode: "timestamp" }).notNull(),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
identifierIdx: index("verification_identifier_idx").on(table.identifier),
valueIdx: index("verification_value_idx").on(table.value),
})
);
// ═══════════════════════════════════════════════════════════════
// OPTIONAL: Additional tables for better-auth plugins
// ═══════════════════════════════════════════════════════════════
/**
* Two-Factor Authentication table (if using 2FA plugin)
*/
export const twoFactor = sqliteTable(
"two_factor",
{
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
secret: text().notNull(), // TOTP secret
backupCodes: text(), // JSON array of backup codes
enabled: integer({ mode: "boolean" }).notNull().default(false),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
userIdIdx: index("two_factor_user_id_idx").on(table.userId),
})
);
/**
* Organizations table (if using organization plugin)
*/
export const organization = sqliteTable("organization", {
id: text().primaryKey(),
name: text().notNull(),
slug: text().notNull().unique(),
logo: text(),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
/**
* Organization members table (if using organization plugin)
*/
export const organizationMember = sqliteTable(
"organization_member",
{
id: text().primaryKey(),
organizationId: text()
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text().notNull(), // "owner", "admin", "member"
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
},
(table) => ({
orgIdIdx: index("org_member_org_id_idx").on(table.organizationId),
userIdIdx: index("org_member_user_id_idx").on(table.userId),
})
);
// ═══════════════════════════════════════════════════════════════
// YOUR APPLICATION TABLES
// ═══════════════════════════════════════════════════════════════
/**
* Example: User profile extension
*/
export const profile = sqliteTable("profile", {
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
bio: text(),
website: text(),
location: text(),
phone: text(),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
/**
* Example: User preferences
*/
export const userPreferences = sqliteTable("user_preferences", {
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
theme: text().notNull().default("system"), // "light", "dark", "system"
language: text().notNull().default("en"),
emailNotifications: integer({ mode: "boolean" }).notNull().default(true),
pushNotifications: integer({ mode: "boolean" }).notNull().default(false),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
// ═══════════════════════════════════════════════════════════════
// Export all schemas for Drizzle
// ═══════════════════════════════════════════════════════════════
export const schema = {
user,
session,
account,
verification,
twoFactor,
organization,
organizationMember,
profile,
userPreferences,
} as const;
/**
* ═══════════════════════════════════════════════════════════════
* USAGE INSTRUCTIONS
* ═══════════════════════════════════════════════════════════════
*
* 1. Save this file as: src/db/schema.ts
*
* 2. Create drizzle.config.ts:
* import type { Config } from "drizzle-kit";
*
* export default {
* out: "./drizzle",
* schema: "./src/db/schema.ts",
* dialect: "sqlite",
* driver: "d1-http",
* dbCredentials: {
* databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
* accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
* token: process.env.CLOUDFLARE_TOKEN!,
* },
* } satisfies Config;
*
* 3. Generate migrations:
* npx drizzle-kit generate
*
* 4. Apply migrations to D1:
* wrangler d1 migrations apply my-app-db --local
* wrangler d1 migrations apply my-app-db --remote
*
* 5. Use in your Worker:
* import { drizzle } from "drizzle-orm/d1";
* import * as schema from "./db/schema";
*
* const db = drizzle(env.DB, { schema });
*
* 6. Query example:
* const users = await db.query.user.findMany({
* where: (user, { eq }) => eq(user.emailVerified, true)
* });
*
* ═══════════════════════════════════════════════════════════════
*/
better-auth with MongoDB
Complete guide for integrating better-auth with MongoDB.
---
Installation
bun add better-auth mongodb---
Setup
Database Connection
`src/db/index.ts`:
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI!;
const client = new MongoClient(uri);
export const db = client.db("your-database-name");
// Optional: Export client for connection management
export { client };Auth Configuration
`src/auth.ts`:
import { betterAuth } from "better-auth";
import { mongoAdapter } from "better-auth/adapters/mongo";
import { db } from "./db";
export const auth = betterAuth({
database: mongoAdapter(db),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.APP_URL,
emailAndPassword: { enabled: true },
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
});---
Collections Created
better-auth automatically creates these collections:
users- User profilessessions- Active sessionsaccounts- OAuth accounts and credentialsverifications- Email verification tokens
Document Schemas
User Document:
{
_id: ObjectId,
name: string,
email: string,
emailVerified: boolean,
image: string | null,
createdAt: Date,
updatedAt: Date,
}Session Document:
{
_id: ObjectId,
userId: ObjectId,
token: string,
expiresAt: Date,
ipAddress: string | null,
userAgent: string | null,
createdAt: Date,
updatedAt: Date,
}Account Document:
{
_id: ObjectId,
userId: ObjectId,
accountId: string,
providerId: string,
accessToken: string | null,
refreshToken: string | null,
accessTokenExpiresAt: Date | null,
refreshTokenExpiresAt: Date | null,
scope: string | null,
idToken: string | null,
password: string | null, // For email/password auth
createdAt: Date,
updatedAt: Date,
}---
MongoDB Atlas Setup
Connection String
MONGODB_URI=mongodb+srv://username:password@cluster.xxxxx.mongodb.net/mydb?retryWrites=true&w=majorityWith Connection Options
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI!, {
maxPoolSize: 10,
minPoolSize: 5,
maxIdleTimeMS: 30000,
connectTimeoutMS: 10000,
socketTimeoutMS: 45000,
});---
Indexes
Create indexes for better performance:
// Run once during setup
async function createIndexes() {
const db = client.db("your-database-name");
// Users collection
await db.collection("users").createIndex({ email: 1 }, { unique: true });
// Sessions collection
await db.collection("sessions").createIndex({ userId: 1 });
await db.collection("sessions").createIndex({ token: 1 }, { unique: true });
await db.collection("sessions").createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// Accounts collection
await db.collection("accounts").createIndex({ userId: 1 });
await db.collection("accounts").createIndex({ providerId: 1, accountId: 1 }, { unique: true });
// Verifications collection
await db.collection("verifications").createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
}---
With Mongoose
If you're already using Mongoose, you can still use the MongoDB adapter:
import mongoose from "mongoose";
import { mongoAdapter } from "better-auth/adapters/mongo";
import { betterAuth } from "better-auth";
// Connect with Mongoose
await mongoose.connect(process.env.MONGODB_URI!);
// Get the underlying MongoDB Db instance
const db = mongoose.connection.db;
export const auth = betterAuth({
database: mongoAdapter(db),
// ...
});---
Connection Management
Graceful Shutdown
import { client } from "./db";
process.on("SIGINT", async () => {
await client.close();
process.exit(0);
});
process.on("SIGTERM", async () => {
await client.close();
process.exit(0);
});Connection Health Check
async function checkConnection() {
try {
await client.db().admin().ping();
return true;
} catch {
return false;
}
}---
Common Queries
Find User by Email
const user = await db.collection("users").findOne({ email: "user@example.com" });Find Active Sessions
const sessions = await db.collection("sessions").find({
userId: new ObjectId(userId),
expiresAt: { $gt: new Date() },
}).toArray();Delete Expired Sessions (Manual Cleanup)
await db.collection("sessions").deleteMany({
expiresAt: { $lt: new Date() },
});---
Environment Variables
MONGODB_URI=mongodb+srv://user:password@cluster.xxxxx.mongodb.net/mydb?retryWrites=true&w=majority
BETTER_AUTH_SECRET=your-secret-here
APP_URL=http://localhost:3000---
Common Issues
"MongoServerError: Authentication failed"
Check your connection string credentials and database name.
Slow Queries
Ensure indexes are created (see Indexes section above).
Connection Timeout in Serverless
Increase connection timeout and use connection pooling:
const client = new MongoClient(uri, {
connectTimeoutMS: 10000,
serverSelectionTimeoutMS: 10000,
});---
Official Resources
- MongoDB Adapter: https://better-auth.com/docs/adapters/mongodb
better-auth with MySQL
Complete guide for integrating better-auth with MySQL and PlanetScale.
---
Drizzle ORM Setup
Installation
bun add better-auth drizzle-orm mysql2 drizzle-kitDatabase Connection
`src/db/index.ts`:
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import * as schema from "./schema";
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
export const db = drizzle(connection, { schema, mode: "default" });Schema Definition
`src/db/schema.ts`:
import { mysqlTable, varchar, boolean, datetime, text } from "drizzle-orm/mysql-core";
export const user = mysqlTable("user", {
id: varchar("id", { length: 36 }).primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
email: varchar("email", { length: 255 }).notNull().unique(),
emailVerified: boolean("emailVerified").notNull().default(false),
image: text("image"),
createdAt: datetime("createdAt").notNull().defaultNow(),
updatedAt: datetime("updatedAt").notNull().defaultNow(),
});
export const session = mysqlTable("session", {
id: varchar("id", { length: 36 }).primaryKey(),
userId: varchar("userId", { length: 36 })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
token: varchar("token", { length: 255 }).notNull().unique(),
expiresAt: datetime("expiresAt").notNull(),
ipAddress: varchar("ipAddress", { length: 45 }),
userAgent: text("userAgent"),
createdAt: datetime("createdAt").notNull().defaultNow(),
updatedAt: datetime("updatedAt").notNull().defaultNow(),
});
export const account = mysqlTable("account", {
id: varchar("id", { length: 36 }).primaryKey(),
userId: varchar("userId", { length: 36 })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accountId: varchar("accountId", { length: 255 }).notNull(),
providerId: varchar("providerId", { length: 255 }).notNull(),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
accessTokenExpiresAt: datetime("accessTokenExpiresAt"),
refreshTokenExpiresAt: datetime("refreshTokenExpiresAt"),
scope: text("scope"),
idToken: text("idToken"),
password: text("password"),
createdAt: datetime("createdAt").notNull().defaultNow(),
updatedAt: datetime("updatedAt").notNull().defaultNow(),
});
export const verification = mysqlTable("verification", {
id: varchar("id", { length: 36 }).primaryKey(),
identifier: varchar("identifier", { length: 255 }).notNull(),
value: text("value").notNull(),
expiresAt: datetime("expiresAt").notNull(),
createdAt: datetime("createdAt").notNull().defaultNow(),
updatedAt: datetime("updatedAt").notNull().defaultNow(),
});Auth Configuration
`src/auth.ts`:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "mysql" }),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.APP_URL,
emailAndPassword: { enabled: true },
});---
PlanetScale Setup
PlanetScale is a serverless MySQL-compatible database.
Installation
bun add better-auth drizzle-orm @planetscale/database drizzle-kitConnection
`src/db/index.ts`:
import { drizzle } from "drizzle-orm/planetscale-serverless";
import { connect } from "@planetscale/database";
import * as schema from "./schema";
const connection = connect({
url: process.env.DATABASE_URL,
});
export const db = drizzle(connection, { schema });Important: Foreign Keys
PlanetScale doesn't support foreign key constraints. Update your schema:
// Remove .references() from columns
export const session = mysqlTable("session", {
id: varchar("id", { length: 36 }).primaryKey(),
userId: varchar("userId", { length: 36 }).notNull(),
// ... other columns (no foreign key reference)
});Auth Configuration for PlanetScale
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "mysql",
// PlanetScale uses Vitess, which needs special handling
}),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.APP_URL,
emailAndPassword: { enabled: true },
});---
Drizzle Kit Configuration
`drizzle.config.ts`:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});For PlanetScale
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "mysql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
// PlanetScale specific
tablesFilter: ["!_vt*"], // Exclude Vitess tables
});---
Migrations
Standard MySQL
bunx drizzle-kit generate
bunx drizzle-kit pushPlanetScale
bunx drizzle-kit generate
bunx drizzle-kit push --force # PlanetScale requires force for some operationsOr use PlanetScale's branching workflow: 1. Create a development branch 2. Push migrations to development branch 3. Create a deploy request 4. Merge to main
---
Connection Pooling
mysql2 Pool
import mysql from "mysql2/promise";
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 10,
queueLimit: 0,
waitForConnections: true,
});
export const db = drizzle(pool, { schema, mode: "default" });---
Environment Variables
Standard MySQL
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=password
DB_NAME=mydb
BETTER_AUTH_SECRET=your-secret
APP_URL=http://localhost:3000PlanetScale
DATABASE_URL=mysql://username:password@aws.connect.psdb.cloud/mydb?ssl={"rejectUnauthorized":true}
BETTER_AUTH_SECRET=your-secret
APP_URL=http://localhost:3000---
Common Issues
"ER_NO_REFERENCED_ROW_2" on PlanetScale
PlanetScale doesn't support foreign keys. Remove .references() from schema.
Connection Timeout in Serverless
Use connection pooling or PlanetScale's serverless driver.
Character Set Issues
Specify UTF-8 in connection:
const connection = await mysql.createConnection({
// ... other options
charset: "utf8mb4",
});---
Official Resources
- MySQL Adapter: https://better-auth.com/docs/adapters/drizzle
- PlanetScale: https://planetscale.com/docs
/**
* Next.js API Route with better-auth
*
* This example demonstrates:
* - PostgreSQL with Drizzle ORM
* - Email/password + social auth
* - Email verification
* - Organizations plugin
* - 2FA plugin
* - Custom error handling
*/
import { betterAuth } from 'better-auth'
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import { twoFactor, organization } from 'better-auth/plugins'
import { sendEmail } from '@/lib/email' // Your email service
// Database connection
const client = postgres(process.env.DATABASE_URL!)
const db = drizzle(client)
// Initialize better-auth
export const auth = betterAuth({
database: db,
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.NEXT_PUBLIC_APP_URL!,
// Email/password authentication
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
// Custom email sending
sendVerificationEmail: async ({ user, url, token }) => {
await sendEmail({
to: user.email,
subject: 'Verify your email',
html: `
<h1>Verify your email</h1>
<p>Click the link below to verify your email address:</p>
<a href="${url}">Verify Email</a>
<p>Or enter this code: <strong>${token}</strong></p>
<p>This link expires in 24 hours.</p>
`
})
},
// Password reset email
sendResetPasswordEmail: async ({ user, url, token }) => {
await sendEmail({
to: user.email,
subject: 'Reset your password',
html: `
<h1>Reset your password</h1>
<p>Click the link below to reset your password:</p>
<a href="${url}">Reset Password</a>
<p>Or enter this code: <strong>${token}</strong></p>
<p>This link expires in 1 hour.</p>
`
})
}
},
// Social providers
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
scope: ['openid', 'email', 'profile']
},
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
scope: ['user:email', 'read:user']
},
microsoft: {
clientId: process.env.MICROSOFT_CLIENT_ID!,
clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
tenantId: process.env.MICROSOFT_TENANT_ID || 'common'
}
},
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update every 24 hours
cookieCache: {
enabled: true,
maxAge: 60 * 5 // 5 minutes
}
},
// Advanced features via plugins
plugins: [
// Two-factor authentication
twoFactor({
methods: ['totp', 'sms'],
issuer: 'MyApp',
sendOTP: async ({ user, otp, method }) => {
if (method === 'sms') {
// Send SMS with OTP (use Twilio, etc.)
console.log(`Send SMS to ${user.phone}: ${otp}`)
}
}
}),
// Organizations and teams
organization({
roles: ['owner', 'admin', 'member'],
permissions: {
owner: ['*'], // All permissions
admin: ['read', 'write', 'delete', 'invite'],
member: ['read']
},
sendInvitationEmail: async ({ email, organizationName, inviteUrl }) => {
await sendEmail({
to: email,
subject: `You've been invited to ${organizationName}`,
html: `
<h1>You've been invited!</h1>
<p>Click the link below to join ${organizationName}:</p>
<a href="${inviteUrl}">Accept Invitation</a>
`
})
}
})
],
// Custom error handling
onError: (error, req) => {
console.error('Auth error:', error)
// Log to your error tracking service (Sentry, etc.)
},
// Success callbacks
onSuccess: async (user, action) => {
console.log(`User ${user.id} performed action: ${action}`)
// Log auth events for security monitoring
}
})
// Type definitions for TypeScript
export type Session = typeof auth.$Infer.Session
export type User = typeof auth.$Infer.User
Next.js Examples
This directory contains better-auth examples for Next.js with PostgreSQL.
Important: These examples are NOT for Cloudflare D1. They use PostgreSQL via Hyperdrive or direct connection.
Files
postgres-example.ts
Complete Next.js API route with better-auth using:
- PostgreSQL (not D1)
- Drizzle ORM with
postgresdriver - Organizations plugin
- 2FA plugin
- Email verification
- Custom error handling
Use this example when:
- Building Next.js application (not Cloudflare Workers)
- Using PostgreSQL database
- Need organizations and 2FA features
Installation:
npm install better-auth drizzle-orm postgresEnvironment variables:
DATABASE_URL=postgresql://user:password@host:5432/database
BETTER_AUTH_SECRET=your-secret
NEXT_PUBLIC_APP_URL=http://localhost:3000
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret---
For Cloudflare D1 examples, see the parent references/ directory:
cloudflare-worker-drizzle.ts- Complete Worker with Drizzle + D1cloudflare-worker-kysely.ts- Complete Worker with Kysely + D1