| name | better-auth |
| description | Production-ready authentication framework for TypeScript with Cloudflare D1 support via Drizzle ORM or Kysely. Use this skill when building auth systems as a self-hosted alternative to Clerk or Auth.js, particularly for Cloudflare Workers projects. CRITICAL: better-auth requires Drizzle ORM or Kysely as database adapters - there is NO direct D1 adapter. Supports social providers (Google, GitHub, Microsoft, Apple), email/password, magic links, 2FA, passkeys, organizations, and RBAC. Prevents 12+ common authentication errors including D1 adapter misconfiguration, schema generation issues, session serialization, CORS, OAuth flows, and JWT token handling. Keywords: better-auth, authentication, cloudflare d1 auth, drizzle orm auth, kysely auth, self-hosted auth, typescript auth, clerk alternative, auth.js alternative, social login, oauth providers, session management, jwt tokens, 2fa, two-factor, passkeys, webauthn, multi-tenant auth, organizations, teams, rbac, role-based access, google auth, github auth, microsoft auth, apple auth, magic links, email password, better-auth setup, drizzle d1, kysely d1, session serialization error, cors auth, d1 adapter |
| license | MIT |
| metadata | [object Object] |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
better-auth Skill
Overview
better-auth is a comprehensive, framework-agnostic authentication and authorization library for TypeScript. It provides a complete auth solution with support for Cloudflare D1 via Drizzle ORM or Kysely, making it an excellent self-hosted alternative to Clerk or Auth.js.
⚠️ CRITICAL: D1 Adapter Requirements
better-auth DOES NOT have a direct d1Adapter(). You MUST use either:
- Drizzle ORM (recommended) -
drizzleAdapter() - Kysely (alternative) - Kysely instance with D1Dialect
Use this skill when:
- Building authentication for Cloudflare Workers + D1 applications
- Need a self-hosted, vendor-independent auth solution
- Migrating from Clerk (avoid vendor lock-in and costs)
- Upgrading from Auth.js (need more features like 2FA, organizations)
- Implementing multi-tenant SaaS with organizations/teams
- Require advanced features: 2FA, passkeys, RBAC, social auth, rate limiting
Package: better-auth@1.3.34 (latest stable verified 2025-11-08)
Installation
Core Packages
Option 1: Drizzle ORM (Recommended)
npm install better-auth drizzle-orm drizzle-kit
# or
pnpm add better-auth drizzle-orm drizzle-kit
Option 2: Kysely
npm install better-auth kysely kysely-d1
# or
pnpm add better-auth kysely kysely-d1
Additional Dependencies
For Cloudflare Workers:
npm install @cloudflare/workers-types hono
For PostgreSQL (via Hyperdrive):
npm install pg drizzle-orm
# or with Kysely
npm install kysely
Social Providers (Optional):
npm install @better-auth/google
npm install @better-auth/github
npm install @better-auth/microsoft
Quick Start: Cloudflare Workers + D1 + Drizzle
Step 1: Create D1 Database
# Create database
wrangler d1 create my-app-db
# Copy the database_id from output
Add to wrangler.toml:
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-here"
[vars]
BETTER_AUTH_URL = "http://localhost:5173"
# Secrets (use: wrangler secret put SECRET_NAME)
# - BETTER_AUTH_SECRET
# - GOOGLE_CLIENT_ID
# - GOOGLE_CLIENT_SECRET
Step 2: Define Database Schema
File: src/db/schema.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// better-auth core tables
export const user = sqliteTable("user", {
id: text().primaryKey(),
name: text().notNull(),
email: text().notNull().unique(),
emailVerified: integer({ mode: "boolean" }).notNull().default(false),
image: text(),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export const session = sqliteTable("session", {
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
token: text().notNull(),
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())`),
});
export const account = sqliteTable("account", {
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accountId: text().notNull(),
providerId: text().notNull(),
accessToken: text(),
refreshToken: text(),
accessTokenExpiresAt: integer({ mode: "timestamp" }),
refreshTokenExpiresAt: integer({ mode: "timestamp" }),
scope: text(),
idToken: text(),
password: text(),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export const verification = sqliteTable("verification", {
id: text().primaryKey(),
identifier: text().notNull(),
value: text().notNull(),
expiresAt: integer({ mode: "timestamp" }).notNull(),
createdAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
// Add your custom tables here
export const profile = sqliteTable("profile", {
id: text().primaryKey(),
userId: text()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
bio: text(),
website: text(),
});
Step 3: Configure Drizzle
File: 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;
Create .env file (for migrations):
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_DATABASE_ID=your-database-id
CLOUDFLARE_TOKEN=your-api-token
Step 4: Generate and Apply Migrations
# Generate migration from schema
npx drizzle-kit generate
# Apply migration to D1 (local)
wrangler d1 migrations apply my-app-db --local
# Apply migration to D1 (production)
wrangler d1 migrations apply my-app-db --remote
Step 5: Initialize Database and Auth
File: src/db/index.ts
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export type Database = ReturnType<typeof createDatabase>;
export function createDatabase(d1: D1Database) {
return drizzle(d1, { schema });
}
File: src/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { Database } from "./db";
type 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;
};
export function createAuth(db: Database, env: Env) {
return betterAuth({
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
// Drizzle adapter with SQLite provider
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}`);
},
},
// Social providers
socialProviders: {
google: env.GOOGLE_CLIENT_ID
? {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET!,
scope: ["openid", "email", "profile"],
}
: undefined,
github: env.GITHUB_CLIENT_ID
? {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET!,
scope: ["user:email", "read:user"],
}
: undefined,
},
// Session configuration
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Update every 24 hours
},
});
}
Step 6: Create Worker with Auth Routes
File: src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createDatabase } from "./db";
import { createAuth } from "./auth";
type 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;
};
const app = new Hono<{ Bindings: Env }>();
// CORS for frontend
app.use(
"/api/*",
cors({
origin: ["http://localhost:3000", "https://yourdomain.com"],
credentials: true,
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
})
);
// 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);
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: session.user,
});
});
// Health check
app.get("/health", (c) => c.json({ status: "ok" }));
export default app;
Step 7: Set Secrets
# Generate a random secret
openssl rand -base64 32
# Set secrets in Wrangler
wrangler secret put BETTER_AUTH_SECRET
# Paste the generated secret
# Optional: Set OAuth secrets
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET
Step 8: Deploy
# Test locally
npm run dev
# Deploy to Cloudflare
wrangler deploy
Alternative: Kysely Adapter Pattern
If you prefer Kysely over Drizzle:
File: src/auth.ts
import { betterAuth } from "better-auth";
import { Kysely, CamelCasePlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
type Env = {
DB: D1Database;
BETTER_AUTH_SECRET: string;
// ... other env vars
};
export function createAuth(env: Env) {
return betterAuth({
secret: env.BETTER_AUTH_SECRET,
// Kysely with D1Dialect
database: {
db: new Kysely({
dialect: new D1Dialect({
database: env.DB,
}),
plugins: [
// CRITICAL: Required if using Drizzle schema with snake_case
new CamelCasePlugin(),
],
}),
type: "sqlite",
},
emailAndPassword: {
enabled: true,
},
// ... other config
});
}
Why CamelCasePlugin?
If your Drizzle schema uses snake_case column names (e.g., email_verified), but better-auth expects camelCase (e.g., emailVerified), the CamelCasePlugin automatically converts between the two.
Client Integration (React)
File: 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",
});
File: src/components/LoginForm.tsx
"use client";
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
export function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const { data, error } = await authClient.signIn.email({
email,
password,
});
if (error) {
console.error("Login failed:", error);
return;
}
window.location.href = "/dashboard";
};
const handleGoogleSignIn = async () => {
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Sign In</button>
<button type="button" onClick={handleGoogleSignIn}>
Sign in with Google
</button>
</form>
);
}
Use React Hook:
"use client";
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>
);
}
Advanced Features
Two-Factor Authentication (2FA)
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";
export const auth = betterAuth({
database: /* ... */,
plugins: [
twoFactor({
methods: ["totp", "sms"],
issuer: "MyApp",
}),
],
});
Client:
// Enable 2FA
const { data, error } = await authClient.twoFactor.enable({
method: "totp",
});
// Verify code
await authClient.twoFactor.verify({
code: "123456",
});
Organizations & Teams
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";
export const auth = betterAuth({
database: /* ... */,
plugins: [
organization({
roles: ["owner", "admin", "member"],
permissions: {
admin: ["read", "write", "delete"],
member: ["read"],
},
}),
],
});
Client:
// Create organization
await authClient.organization.create({
name: "Acme Corp",
slug: "acme",
});
// Invite member
await authClient.organization.inviteMember({
organizationId: "org_123",
email: "user@example.com",
role: "member",
});
// Check permissions
const canDelete = await authClient.organization.hasPermission({
organizationId: "org_123",
permission: "delete",
});
Rate Limiting with KV
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,
});
},
},
}),
],
});
}
Known Issues & Solutions
Issue 1: "d1Adapter is not exported" Error
Problem: Code shows import { d1Adapter } from 'better-auth/adapters/d1' but this doesn't exist.
Symptoms: TypeScript error or runtime error about missing export.
Solution: Use Drizzle or Kysely instead:
// ❌ WRONG - This doesn't exist
import { d1Adapter } from 'better-auth/adapters/d1'
database: d1Adapter(env.DB)
// ✅ CORRECT - Use Drizzle
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { drizzle } from 'drizzle-orm/d1'
const db = drizzle(env.DB, { schema })
database: drizzleAdapter(db, { provider: "sqlite" })
// ✅ CORRECT - Use Kysely
import { Kysely } from 'kysely'
import { D1Dialect } from 'kysely-d1'
database: {
db: new Kysely({ dialect: new D1Dialect({ database: env.DB }) }),
type: "sqlite"
}
Source: Verified from 4 production repositories using better-auth + D1
Issue 2: Schema Generation Fails
Problem: npx better-auth migrate doesn't create D1-compatible schema.
Symptoms: Migration SQL has wrong syntax or doesn't work with D1.
Solution: Use Drizzle Kit to generate migrations:
# Generate migration from Drizzle schema
npx drizzle-kit generate
# Apply to D1
wrangler d1 migrations apply my-app-db --remote
Why: Drizzle Kit generates SQLite-compatible SQL that works with D1.
Issue 3: "CamelCase" vs "snake_case" Column Mismatch
Problem: Database has email_verified but better-auth expects emailVerified.
Symptoms: Session reads fail, user data missing fields.
Solution: Use CamelCasePlugin with Kysely or configure Drizzle properly:
With Kysely:
import { CamelCasePlugin } from "kysely";
new Kysely({
dialect: new D1Dialect({ database: env.DB }),
plugins: [new CamelCasePlugin()], // Converts between naming conventions
})
With Drizzle: Define schema with camelCase from the start (as shown in examples).
Issue 4: D1 Eventual Consistency
Problem: Session reads immediately after write return stale data.
Symptoms: User logs in but getSession() returns null on next request.
Solution: Use Cloudflare KV for session storage (strong consistency):
import { betterAuth } from "better-auth";
export function createAuth(db: Database, env: Env) {
return betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
session: {
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);
},
},
},
});
}
Add to wrangler.toml:
[[kv_namespaces]]
binding = "SESSIONS_KV"
id = "your-kv-namespace-id"
Issue 5: CORS Errors for SPA Applications
Problem: CORS errors when auth API is on different origin than frontend.
Symptoms: Access-Control-Allow-Origin errors in browser console.
Solution: Configure CORS headers in Worker:
import { cors } from "hono/cors";
app.use(
"/api/auth/*",
cors({
origin: ["https://yourdomain.com", "http://localhost:3000"],
credentials: true, // Allow cookies
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
})
);
Issue 6: OAuth Redirect URI Mismatch
Problem: Social sign-in fails with "redirect_uri_mismatch" error.
Symptoms: Google/GitHub OAuth returns error after user consent.
Solution: Ensure exact match in OAuth provider settings:
Provider setting: https://yourdomain.com/api/auth/callback/google
better-auth URL: https://yourdomain.com/api/auth/callback/google
❌ Wrong: http vs https, trailing slash, subdomain mismatch
✅ Right: Exact character-for-character match
Check better-auth callback URL:
// It's always: {baseURL}/api/auth/callback/{provider}
const callbackURL = `${env.BETTER_AUTH_URL}/api/auth/callback/google`;
console.log("Configure this URL in Google Console:", callbackURL);
Issue 7: Missing Dependencies
Problem: TypeScript errors or runtime errors about missing packages.
Symptoms: Cannot find module 'drizzle-orm' or similar.
Solution: Install all required packages:
For Drizzle approach:
npm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types
For Kysely approach:
npm install better-auth kysely kysely-d1 @cloudflare/workers-types
Issue 8: Email Verification Not Sending
Problem: Email verification links never arrive.
Symptoms: User signs up, but no email received.
Solution: Implement sendVerificationEmail handler:
export const auth = betterAuth({
database: /* ... */,
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendVerificationEmail: async ({ user, url, token }) => {
// Use your email service (SendGrid, Resend, etc.)
await sendEmail({
to: user.email,
subject: "Verify your email",
html: `
<p>Click the link below to verify your email:</p>
<a href="${url}">Verify Email</a>
<p>Or use this code: ${token}</p>
`,
});
},
},
});
For Cloudflare: Use Cloudflare Email Routing or external service (Resend, SendGrid).
Issue 9: Session Expires Too Quickly
Problem: Session expires unexpectedly or never expires.
Symptoms: User logged out unexpectedly or session persists after logout.
Solution: Configure session expiration:
export const auth = betterAuth({
database: /* ... */,
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days (in seconds)
updateAge: 60 * 60 * 24, // Update session every 24 hours
},
});
Issue 10: Social Provider Missing User Data
Problem: Social sign-in succeeds but missing user data (name, avatar).
Symptoms: session.user.name is null after Google/GitHub sign-in.
Solution: Request additional scopes:
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"], // Include 'profile' for name/image
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["user:email", "read:user"], // 'read:user' for full profile
},
}
Issue 11: TypeScript Errors with Drizzle Schema
Problem: TypeScript complains about schema types.
Symptoms: Type 'DrizzleD1Database' is not assignable to...
Solution: Export proper types from database:
// src/db/index.ts
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "./schema";
export type Database = DrizzleD1Database<typeof schema>;
export function createDatabase(d1: D1Database): Database {
return drizzle(d1, { schema });
}
Issue 12: Wrangler Dev Mode Not Working
Problem: wrangler dev fails with database errors.
Symptoms: "Database not found" or migration errors in local dev.
Solution: Apply migrations locally first:
# Apply migrations to local D1
wrangler d1 migrations apply my-app-db --local
# Then run dev server
wrangler dev
Comparison: better-auth vs Alternatives
| Feature | better-auth | Clerk | Auth.js |
|---|---|---|---|
| Hosting | Self-hosted | Third-party | Self-hosted |
| Cost | Free (OSS) | $25/mo+ | Free (OSS) |
| Cloudflare D1 | ✅ Drizzle/Kysely | ❌ No | ✅ Adapter |
| Social Auth | ✅ 10+ providers | ✅ Many | ✅ Many |
| 2FA/Passkeys | ✅ Plugin | ✅ Built-in | ⚠️ Limited |
| Organizations | ✅ Plugin | ✅ Built-in | ❌ No |
| Multi-tenant | ✅ Plugin | ✅ Yes | ❌ No |
| RBAC | ✅ Plugin | ✅ Yes | ⚠️ Custom |
| Magic Links | ✅ Built-in | ✅ Yes | ✅ Yes |
| Email/Password | ✅ Built-in | ✅ Yes | ✅ Yes |
| Session Mgmt | ✅ JWT + DB | ✅ JWT | ✅ JWT + DB |
| TypeScript | ✅ First-class | ✅ Yes | ✅ Yes |
| Framework Support | ✅ Agnostic | ⚠️ React-focused | ✅ Agnostic |
| Vendor Lock-in | ✅ None | ❌ High | ✅ None |
| Customization | ✅ Full control | ⚠️ Limited | ✅ Full control |
| Production Ready | ✅ Yes | ✅ Yes | ✅ Yes |
Recommendation:
- Use better-auth if: Self-hosted, Cloudflare D1, want full control, avoid vendor lock-in
- Use Clerk if: Want managed service, don't mind cost, need fastest setup
- Use Auth.js if: Already using Next.js, basic needs, familiar with it
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:
- Export user data from Clerk (CSV or API)
- 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, }); } - 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; - Update middleware for session verification
- 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:
- Database schema: Auth.js and better-auth use similar schemas, but column names differ
- 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: { /* ... */ }, }, }); - Update client hooks:
// Before import { useSession } from "next-auth/react"; // After import { authClient } from "@/lib/auth-client"; const { data: session } = authClient.useSession();
Best Practices
Security
- Always use HTTPS in production (no exceptions)
- Rotate secrets regularly:
openssl rand -base64 32 wrangler secret put BETTER_AUTH_SECRET - 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"); } }, }; - Enable rate limiting for auth endpoints
- Log auth events for security monitoring
Performance
- Cache session lookups (use KV for Workers)
- Use indexes on frequently queried fields:
CREATE INDEX idx_sessions_user_id ON session(userId); CREATE INDEX idx_accounts_provider ON account(providerId, accountId); - Minimize session data (only essential fields)
Development Workflow
Use 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
Bundled Resources
This skill includes the following reference implementations:
scripts/setup-d1-drizzle.sh- Complete D1 + Drizzle setup automationreferences/cloudflare-worker-drizzle.ts- Complete Worker with Drizzle authreferences/cloudflare-worker-kysely.ts- Complete Worker with Kysely authreferences/database-schema.ts- Complete better-auth Drizzle schemareferences/react-client-hooks.tsx- React components with auth hooksassets/auth-flow-diagram.md- Visual flow diagrams
Use Read tool to access these files when needed.
Token Efficiency
Without this skill: ~20,000 tokens (setup trial-and-error, debugging D1 adapter, schema generation, CORS, OAuth) With this skill: ~6,000 tokens (direct implementation from correct patterns) Savings: ~70% (14,000 tokens)
Errors prevented: 12 common issues documented with solutions
Additional Resources
- Official 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
Production Examples
Verified working repositories (all use Drizzle or Kysely):
- zwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle
- matthewlynch/better-auth-react-router-cloudflare-d1 - Kysely
- foxlau/react-router-v7-better-auth - Drizzle
- zpg6/better-auth-cloudflare - Drizzle (includes CLI)
None use a direct d1Adapter - all require Drizzle/Kysely.
Version Compatibility
Tested with:
better-auth@1.3.34drizzle-orm@0.44.7drizzle-kit@0.31.6kysely@0.28.8kysely-d1@0.4.0@cloudflare/workers-types@latesthono@4.0.0- Node.js 18+, Bun 1.0+
Breaking changes: Check changelog when upgrading: https://github.com/better-auth/better-auth/releases
Last verified: 2025-11-08 | Skill version: 2.0.0 | Breaking change: Corrected D1 adapter patterns