better-auth
About
better-auth is a production-ready TypeScript authentication framework designed as a self-hosted alternative to Clerk or Auth.js, particularly for Cloudflare Workers projects. It supports comprehensive auth features including social providers, email/password, magic links, 2FA, passkeys, and RBAC. Critically, it requires Drizzle ORM or Kysely as database adapters for Cloudflare D1, as there is no direct D1 adapter.
Quick Install
Claude Code
Recommended/plugin add https://github.com/jezweb/claude-skillsgit clone https://github.com/jezweb/claude-skills.git ~/.claude/skills/better-authCopy and paste this command in Claude Code to install this skill
Documentation
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: [email protected] (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,
},
// Email verification configuration
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
// TODO: Implement email sending
// Use Resend, SendGrid, or Cloudflare Email Routing
console.log(`Verification email for ${user.email}: ${url}`);
},
sendOnSignUp: true,
autoSignInAfterVerification: true,
expiresIn: 3600, // 1 hour
},
// 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/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8787",
});
// For other frameworks:
// Vue: import { createAuthClient } from "better-auth/vue"
// Svelte: import { createAuthClient } from "better-auth/svelte"
// Vanilla: import { createAuthClient } from "better-auth/client"
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"; // Uses better-auth/react
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: "[email protected]",
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,
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
// 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>
`,
});
},
sendOnSignUp: true,
autoSignInAfterVerification: true,
expiresIn: 3600, // 1 hour
},
});
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 D1 repositories (all use Drizzle or Kysely):
- zpg6/better-auth-cloudflare - Drizzle + D1 (includes CLI)
- zwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle + D1
- foxlau/react-router-v7-better-auth - Drizzle + D1
- matthewlynch/better-auth-react-router-cloudflare-d1 - Kysely + D1
None use a direct d1Adapter - all require Drizzle/Kysely.
Version Compatibility
Tested with:
[email protected][email protected][email protected][email protected][email protected]@cloudflare/workers-types@latest[email protected]- Node.js 18+, Bun 1.0+
Breaking changes: Check changelog when upgrading: https://github.com/better-auth/better-auth/releases
Last verified: 2025-11-17 | Skill version: 2.0.1 | Changes: Email verification config structure, React imports, production repo list
GitHub Repository
Related Skills
sglang
MetaSGLang is a high-performance LLM serving framework that specializes in fast, structured generation for JSON, regex, and agentic workflows using its RadixAttention prefix caching. It delivers significantly faster inference, especially for tasks with repeated prefixes, making it ideal for complex, structured outputs and multi-turn conversations. Choose SGLang over alternatives like vLLM when you need constrained decoding or are building applications with extensive prefix sharing.
evaluating-llms-harness
TestingThis Claude Skill runs the lm-evaluation-harness to benchmark LLMs across 60+ standardized academic tasks like MMLU and GSM8K. It's designed for developers to compare model quality, track training progress, or report academic results. The tool supports various backends including HuggingFace and vLLM models.
llamaguard
OtherLlamaGuard is Meta's 7-8B parameter model for moderating LLM inputs and outputs across six safety categories like violence and hate speech. It offers 94-95% accuracy and can be deployed using vLLM, Hugging Face, or Amazon SageMaker. Use this skill to easily integrate content filtering and safety guardrails into your AI applications.
langchain
MetaLangChain is a framework for building LLM applications using agents, chains, and RAG pipelines. It supports multiple LLM providers, offers 500+ integrations, and includes features like tool calling and memory management. Use it for rapid prototyping and deploying production systems like chatbots, autonomous agents, and question-answering services.
