
Better Auth
- 52 installs
- 51 repo stars
- Updated November 25, 2025
- ovachiever/droid-tings
Helps with security tasks during AI-assisted development.
About
better-auth is a Claude Code skill for security. It helps solo builders move faster with AI-assisted coding.
- better-auth
- Security
- AI-coding skill
Better Auth by the numbers
- 52 all-time installs (skills.sh)
- Ranked #1,298 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ovachiever/droid-tings --skill better-authAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 52 |
|---|---|
| repo stars | ★ 51 |
| Last updated | November 25, 2025 |
| Repository | ovachiever/droid-tings ↗ |
What it does
Helps with security tasks during AI-assisted development.
Files
better-auth - D1 Adapter & Error Prevention Guide
Package: better-auth@1.4.0 (Nov 22, 2025) Breaking Changes: ESM-only (v1.4.0), Multi-team table changes (v1.3), D1 requires Drizzle/Kysely (no direct adapter)
---
⚠️ CRITICAL: D1 Adapter Requirement
better-auth DOES NOT have d1Adapter(). You MUST use:
- Drizzle ORM (recommended):
drizzleAdapter(db, { provider: "sqlite" }) - Kysely:
new Kysely({ dialect: new D1Dialect({ database: env.DB }) })
See Issue #1 below for details.
---
What's New in v1.4.0 (Nov 22, 2025)
Major Features:
- Stateless session management - Sessions without database storage
- ESM-only package ⚠️ Breaking: CommonJS no longer supported
- JWT key rotation - Automatic key rotation for enhanced security
- SCIM provisioning - Enterprise user provisioning protocol
- @standard-schema/spec - Replaces ZodType for validation
- CaptchaFox integration - Built-in CAPTCHA support
- Automatic server-side IP detection
- Cookie-based account data storage
- Multiple passkey origins support
- RP-Initiated Logout endpoint (OIDC)
📚 Docs: https://www.better-auth.com/changelogs
---
What's New in v1.3 (July 2025)
Major Features:
- SSO with SAML 2.0 - Enterprise single sign-on (moved to separate
@better-auth/ssopackage) - Multi-team support ⚠️ Breaking:
teamIdremoved from member table, newteamMemberstable required - Additional fields - Custom fields for organization/member/invitation models
- Performance improvements and bug fixes
📚 Docs: https://www.better-auth.com/blog/1-3
---
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.
---
Framework Integrations
TanStack Start
⚠️ CRITICAL: TanStack Start requires the reactStartCookies plugin to handle cookie setting properly.
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { reactStartCookies } from "better-auth/react-start";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
plugins: [
twoFactor(),
organization(),
reactStartCookies(), // ⚠️ MUST be LAST plugin
],
});Why it's needed: TanStack Start uses a special cookie handling system. Without this plugin, auth functions like signInEmail() and signUpEmail() won't set cookies properly, causing authentication to fail.
Important: The reactStartCookies plugin must be the last plugin in the array.
API Route Setup (/src/routes/api/auth/$.ts):
import { auth } from '@/lib/auth'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
})📚 Official Docs: https://www.better-auth.com/docs/integrations/tanstack
---
Available Plugins (v1.3+)
Better Auth provides plugins for advanced authentication features:
| Plugin | Import | Description | Docs |
|---|---|---|---|
| OIDC Provider | better-auth/plugins | Build your own OpenID Connect provider (become an OAuth provider for other apps) | 📚 |
| SSO | better-auth/plugins | Enterprise Single Sign-On with OIDC, OAuth2, and SAML 2.0 support | 📚 |
| Stripe | better-auth/plugins | Payment and subscription management (stable as of v1.3+) | 📚 |
| MCP | better-auth/plugins | Act as OAuth provider for Model Context Protocol (MCP) clients | 📚 |
| Expo | better-auth/expo | React Native/Expo integration with secure cookie management | 📚 |
---
API Reference
Overview: What You Get For Free
When you call auth.handler(), better-auth automatically exposes 80+ production-ready REST endpoints at /api/auth/*. Every endpoint is also available as a server-side method via auth.api.* for programmatic use.
This dual-layer API system means:
- Clients (React, Vue, mobile apps) call HTTP endpoints directly
- Server-side code (middleware, background jobs) uses
auth.api.*methods - Zero boilerplate - no need to write auth endpoints manually
Time savings: Building this from scratch = ~220 hours. With better-auth = ~4-8 hours. 97% reduction.
---
Auto-Generated HTTP Endpoints
All endpoints are automatically exposed at /api/auth/* when using auth.handler().
Core Authentication Endpoints
| Endpoint | Method | Description |
|---|---|---|
/sign-up/email | POST | Register with email/password |
/sign-in/email | POST | Authenticate with email/password |
/sign-out | POST | Logout user |
/change-password | POST | Update password (requires current password) |
/forget-password | POST | Initiate password reset flow |
/reset-password | POST | Complete password reset with token |
/send-verification-email | POST | Send email verification link |
/verify-email | GET | Verify email with token (?token=<token>) |
/get-session | GET | Retrieve current session |
/list-sessions | GET | Get all active user sessions |
/revoke-session | POST | End specific session |
/revoke-other-sessions | POST | End all sessions except current |
/revoke-sessions | POST | End all user sessions |
/update-user | POST | Modify user profile (name, image) |
/change-email | POST | Update email address |
/set-password | POST | Add password to OAuth-only account |
/delete-user | POST | Remove user account |
/list-accounts | GET | Get linked authentication providers |
/link-social | POST | Connect OAuth provider to account |
/unlink-account | POST | Disconnect provider |
Social OAuth Endpoints
| Endpoint | Method | Description |
|---|---|---|
/sign-in/social | POST | Initiate OAuth flow (provider specified in body) |
/callback/:provider | GET | OAuth callback handler (e.g., /callback/google) |
/get-access-token | GET | Retrieve provider access token |
Example OAuth flow:
// Client initiates
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
// better-auth handles redirect to Google
// Google redirects back to /api/auth/callback/google
// better-auth creates session automatically---
Plugin Endpoints
Two-Factor Authentication (2FA Plugin)
import { twoFactor } from "better-auth/plugins";| Endpoint | Method | Description |
|---|---|---|
/two-factor/enable | POST | Activate 2FA for user |
/two-factor/disable | POST | Deactivate 2FA |
/two-factor/get-totp-uri | GET | Get QR code URI for authenticator app |
/two-factor/verify-totp | POST | Validate TOTP code from authenticator |
/two-factor/send-otp | POST | Send OTP via email |
/two-factor/verify-otp | POST | Validate email OTP |
/two-factor/generate-backup-codes | POST | Create recovery codes |
/two-factor/verify-backup-code | POST | Use backup code for login |
/two-factor/view-backup-codes | GET | View current backup codes |
📚 Docs: https://www.better-auth.com/docs/plugins/2fa
Organization Plugin (Multi-Tenant SaaS)
import { organization } from "better-auth/plugins";Organizations (10 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/create | POST | Create organization |
/organization/list | GET | List user's organizations |
/organization/get-full | GET | Get complete org details |
/organization/update | PUT | Modify organization |
/organization/delete | DELETE | Remove organization |
/organization/check-slug | GET | Verify slug availability |
/organization/set-active | POST | Set active organization context |
Members (8 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/list-members | GET | Get organization members |
/organization/add-member | POST | Add member directly |
/organization/remove-member | DELETE | Remove member |
/organization/update-member-role | PUT | Change member role |
/organization/get-active-member | GET | Get current member info |
/organization/leave | POST | Leave organization |
Invitations (7 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/invite-member | POST | Send invitation email |
/organization/accept-invitation | POST | Accept invite |
/organization/reject-invitation | POST | Reject invite |
/organization/cancel-invitation | POST | Cancel pending invite |
/organization/get-invitation | GET | Get invitation details |
/organization/list-invitations | GET | List org invitations |
/organization/list-user-invitations | GET | List user's pending invites |
Teams (8 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/create-team | POST | Create team within org |
/organization/list-teams | GET | List organization teams |
/organization/update-team | PUT | Modify team |
/organization/remove-team | DELETE | Remove team |
/organization/set-active-team | POST | Set active team context |
/organization/list-team-members | GET | List team members |
/organization/add-team-member | POST | Add member to team |
/organization/remove-team-member | DELETE | Remove team member |
Permissions & Roles (6 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/has-permission | POST | Check if user has permission |
/organization/create-role | POST | Create custom role |
/organization/delete-role | DELETE | Delete custom role |
/organization/list-roles | GET | List all roles |
/organization/get-role | GET | Get role details |
/organization/update-role | PUT | Modify role permissions |
📚 Docs: https://www.better-auth.com/docs/plugins/organization
Admin Plugin
import { admin } from "better-auth/plugins";| Endpoint | Method | Description |
|---|---|---|
/admin/create-user | POST | Create user as admin |
/admin/list-users | GET | List all users (with filters/pagination) |
/admin/set-role | POST | Assign user role |
/admin/set-user-password | POST | Change user password |
/admin/update-user | PUT | Modify user details |
/admin/remove-user | DELETE | Delete user account |
/admin/ban-user | POST | Ban user account |
/admin/unban-user | POST | Unban user |
/admin/list-user-sessions | GET | Get user's active sessions |
/admin/revoke-user-session | DELETE | End specific user session |
/admin/revoke-user-sessions | DELETE | End all user sessions |
/admin/impersonate-user | POST | Start impersonating user |
/admin/stop-impersonating | POST | End impersonation session |
📚 Docs: https://www.better-auth.com/docs/plugins/admin
Other Plugin Endpoints
Passkey Plugin (5 endpoints) - Docs:
/passkey/add,/sign-in/passkey,/passkey/list,/passkey/delete,/passkey/update
Magic Link Plugin (2 endpoints) - Docs:
/sign-in/magic-link,/magic-link/verify
Username Plugin (2 endpoints) - Docs:
/sign-in/username,/username/is-available
Phone Number Plugin (5 endpoints) - Docs:
/sign-in/phone-number,/phone-number/send-otp,/phone-number/verify,/phone-number/request-password-reset,/phone-number/reset-password
Email OTP Plugin (6 endpoints) - Docs:
/email-otp/send-verification-otp,/email-otp/check-verification-otp,/sign-in/email-otp,/email-otp/verify-email,/forget-password/email-otp,/email-otp/reset-password
Anonymous Plugin (1 endpoint) - Docs:
/sign-in/anonymous
JWT Plugin (2 endpoints) - Docs:
/token(get JWT),/jwks(public key for verification)
OpenAPI Plugin (2 endpoints) - Docs:
/reference(interactive API docs with Scalar UI)/generate-openapi-schema(get OpenAPI spec as JSON)
---
Server-Side API Methods (auth.api.*)
Every HTTP endpoint has a corresponding server-side method. Use these for:
- Server-side middleware (protecting routes)
- Background jobs (user cleanup, notifications)
- Admin operations (bulk user management)
- Custom auth flows (programmatic session creation)
Core API Methods
// Authentication
await auth.api.signUpEmail({
body: { email, password, name },
headers: request.headers,
});
await auth.api.signInEmail({
body: { email, password, rememberMe: true },
headers: request.headers,
});
await auth.api.signOut({ headers: request.headers });
// Session Management
const session = await auth.api.getSession({ headers: request.headers });
await auth.api.listSessions({ headers: request.headers });
await auth.api.revokeSession({
body: { token: "session_token_here" },
headers: request.headers,
});
// User Management
await auth.api.updateUser({
body: { name: "New Name", image: "https://..." },
headers: request.headers,
});
await auth.api.changeEmail({
body: { newEmail: "newemail@example.com" },
headers: request.headers,
});
await auth.api.deleteUser({
body: { password: "current_password" },
headers: request.headers,
});
// Account Linking
await auth.api.linkSocialAccount({
body: { provider: "google" },
headers: request.headers,
});
await auth.api.unlinkAccount({
body: { providerId: "google", accountId: "google_123" },
headers: request.headers,
});Plugin API Methods
2FA Plugin:
// Enable 2FA
const { totpUri, backupCodes } = await auth.api.enableTwoFactor({
body: { issuer: "MyApp" },
headers: request.headers,
});
// Verify TOTP code
await auth.api.verifyTOTP({
body: { code: "123456", trustDevice: true },
headers: request.headers,
});
// Generate backup codes
const { backupCodes } = await auth.api.generateBackupCodes({
headers: request.headers,
});Organization Plugin:
// Create organization
const org = await auth.api.createOrganization({
body: { name: "Acme Corp", slug: "acme" },
headers: request.headers,
});
// Add member
await auth.api.addMember({
body: {
userId: "user_123",
role: "admin",
organizationId: org.id,
},
headers: request.headers,
});
// Check permissions
const hasPermission = await auth.api.hasPermission({
body: {
organizationId: org.id,
permission: "users:delete",
},
headers: request.headers,
});Admin Plugin:
// List users with pagination
const users = await auth.api.listUsers({
query: {
search: "john",
limit: 10,
offset: 0,
sortBy: "createdAt",
sortOrder: "desc",
},
headers: request.headers,
});
// Ban user
await auth.api.banUser({
body: {
userId: "user_123",
reason: "Violation of ToS",
expiresAt: new Date("2025-12-31"),
},
headers: request.headers,
});
// Impersonate user (for admin support)
const impersonationSession = await auth.api.impersonateUser({
body: {
userId: "user_123",
expiresIn: 3600, // 1 hour
},
headers: request.headers,
});---
When to Use Which
| Use Case | Use HTTP Endpoints | Use auth.api.* Methods |
|---|---|---|
| Client-side auth | ✅ Yes | ❌ No |
| Server middleware | ❌ No | ✅ Yes |
| Background jobs | ❌ No | ✅ Yes |
| Admin dashboards | ✅ Yes (from client) | ✅ Yes (from server) |
| Custom auth flows | ❌ No | ✅ Yes |
| Mobile apps | ✅ Yes | ❌ No |
| API routes | ✅ Yes (proxy to handler) | ✅ Yes (direct calls) |
Example: Protected Route Middleware
import { Hono } from "hono";
import { createAuth } from "./auth";
import { createDatabase } from "./db";
const app = new Hono<{ Bindings: Env }>();
// Middleware using server-side API
app.use("/api/protected/*", async (c, next) => {
const db = createDatabase(c.env.DB);
const auth = createAuth(db, c.env);
// Use server-side method
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
// Attach to context
c.set("user", session.user);
c.set("session", session.session);
await next();
});
// Protected route
app.get("/api/protected/profile", async (c) => {
const user = c.get("user");
return c.json({ user });
});---
Discovering Available Endpoints
Use the OpenAPI plugin to see all endpoints in your configuration:
import { betterAuth } from "better-auth";
import { openAPI } from "better-auth/plugins";
export const auth = betterAuth({
database: /* ... */,
plugins: [
openAPI(), // Adds /api/auth/reference endpoint
],
});Interactive documentation: Visit http://localhost:8787/api/auth/reference
This shows a Scalar UI with:
- ✅ All available endpoints grouped by feature
- ✅ Request/response schemas with types
- ✅ Try-it-out functionality (test endpoints in browser)
- ✅ Authentication requirements
- ✅ Code examples in multiple languages
Programmatic access:
const schema = await auth.api.generateOpenAPISchema();
console.log(JSON.stringify(schema, null, 2));
// Returns full OpenAPI 3.0 spec---
Quantified Time Savings
Building from scratch (manual implementation):
- Core auth endpoints (sign-up, sign-in, OAuth, sessions): 40 hours
- Email verification & password reset: 10 hours
- 2FA system (TOTP, backup codes, email OTP): 20 hours
- Organizations (teams, invitations, RBAC): 60 hours
- Admin panel (user management, impersonation): 30 hours
- Testing & debugging: 50 hours
- Security hardening: 20 hours
Total manual effort: ~220 hours (5.5 weeks full-time)
With better-auth:
- Initial setup: 2-4 hours
- Customization & styling: 2-4 hours
Total with better-auth: 4-8 hours
Savings: ~97% development time
---
Key Takeaway
better-auth provides 80+ production-ready endpoints covering:
- ✅ Core authentication (20 endpoints)
- ✅ 2FA & passwordless (15 endpoints)
- ✅ Organizations & teams (35 endpoints)
- ✅ Admin & user management (15 endpoints)
- ✅ Social OAuth (auto-configured callbacks)
- ✅ OpenAPI documentation (interactive UI)
You write zero endpoint code. Just configure features and call auth.handler().
---
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 --remoteWhy: 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 matchCheck 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-typesFor 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---
Issue 13: User Data Updates Not Reflecting in UI (with TanStack Query)
Problem: After updating user data (e.g., avatar, name), changes don't appear in useSession() despite calling queryClient.invalidateQueries().
Symptoms: Avatar image or user profile data appears stale after successful update. TanStack Query cache shows updated data, but better-auth session still shows old values.
Root Cause: better-auth uses nanostores for session state management, not TanStack Query. Calling queryClient.invalidateQueries() only invalidates React Query cache, not the better-auth nanostore.
Solution: Manually notify the nanostore after updating user data:
// Update user data
const { data, error } = await authClient.updateUser({
image: newAvatarUrl,
name: newName
})
if (!error) {
// Manually invalidate better-auth session state
authClient.$store.notify('$sessionSignal')
// Optional: Also invalidate React Query if using it for other data
queryClient.invalidateQueries({ queryKey: ['user-profile'] })
}When to use:
- Using better-auth + TanStack Query together
- Updating user profile fields (name, image, email)
- Any operation that modifies session user data client-side
Alternative: Call refetch() from useSession(), but $store.notify() is more direct:
const { data: session, refetch } = authClient.useSession()
// After update
await refetch()Note: $store is an undocumented internal API. This pattern is production-validated but may change in future better-auth versions.
Source: Community-discovered pattern, production use verified
---
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();---
Additional Resources
Official Documentation
- Homepage: https://better-auth.com
- Introduction: https://www.better-auth.com/docs/introduction
- Installation: https://www.better-auth.com/docs/installation
- Basic Usage: https://www.better-auth.com/docs/basic-usage
Core Concepts
- Session Management: https://www.better-auth.com/docs/concepts/session-management
- Users & Accounts: https://www.better-auth.com/docs/concepts/users-accounts
- Client SDK: https://www.better-auth.com/docs/concepts/client
- Plugins System: https://www.better-auth.com/docs/concepts/plugins
Authentication Methods
- Email & Password: https://www.better-auth.com/docs/authentication/email-password
- OAuth Providers: https://www.better-auth.com/docs/concepts/oauth
Plugin Documentation
Core Plugins:
- 2FA (Two-Factor): https://www.better-auth.com/docs/plugins/2fa
- Organization: https://www.better-auth.com/docs/plugins/organization
- Admin: https://www.better-auth.com/docs/plugins/admin
- Multi-Session: https://www.better-auth.com/docs/plugins/multi-session
- API Key: https://www.better-auth.com/docs/plugins/api-key
- Generic OAuth: https://www.better-auth.com/docs/plugins/generic-oauth
Passwordless Plugins:
- Passkey: https://www.better-auth.com/docs/plugins/passkey
- Magic Link: https://www.better-auth.com/docs/plugins/magic-link
- Email OTP: https://www.better-auth.com/docs/plugins/email-otp
- Phone Number: https://www.better-auth.com/docs/plugins/phone-number
- Anonymous: https://www.better-auth.com/docs/plugins/anonymous
Advanced Plugins:
- Username: https://www.better-auth.com/docs/plugins/username
- JWT: https://www.better-auth.com/docs/plugins/jwt
- OpenAPI: https://www.better-auth.com/docs/plugins/open-api
- OIDC Provider: https://www.better-auth.com/docs/plugins/oidc-provider
- SSO: https://www.better-auth.com/docs/plugins/sso
- Stripe: https://www.better-auth.com/docs/plugins/stripe
- MCP: https://www.better-auth.com/docs/plugins/mcp
Framework Integrations
- TanStack Start: https://www.better-auth.com/docs/integrations/tanstack
- Expo (React Native): https://www.better-auth.com/docs/integrations/expo
Community & Support
- GitHub: https://github.com/better-auth/better-auth (22.4k ⭐)
- Examples: https://github.com/better-auth/better-auth/tree/main/examples
- Discord: https://discord.gg/better-auth
- Changelog: https://github.com/better-auth/better-auth/releases
Related Documentation
- Drizzle ORM: https://orm.drizzle.team/docs/get-started-sqlite
- Kysely: https://kysely.dev/
---
Production Examples
Verified working D1 repositories (all use Drizzle or Kysely):
1. zpg6/better-auth-cloudflare - Drizzle + D1 (includes CLI) 2. zwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle + D1 3. foxlau/react-router-v7-better-auth - Drizzle + D1 4. matthewlynch/better-auth-react-router-cloudflare-d1 - Kysely + D1
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
---
Token Efficiency:
- Without skill: ~28,000 tokens (D1 adapter errors, TanStack Start cookies, nanostore invalidation, OAuth flows, API discovery)
- With skill: ~5,600 tokens (focused on errors + breaking changes + API reference)
- Savings: ~80% (~22,400 tokens)
Errors prevented: 13 documented issues with exact solutions Key value: D1 adapter requirement, v1.4.0/v1.3 breaking changes, TanStack Start fix, nanostore pattern, 80+ endpoint reference
---
Last verified: 2025-11-22 | Skill version: 3.0.0 | Changes: Added v1.4.0 (ESM-only, stateless sessions, SCIM) and v1.3 (SSO/SAML, multi-team) knowledge gaps. Removed tutorial/setup (~700 lines). Focused on error prevention + breaking changes + API reference.
{
"name": "better-auth",
"description": "Build authentication systems for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, and RBAC. Self-hosted alternative to Clerk/Auth.js. IMPORTANT: Requires Drizzle ORM or Kysely for D1 - no direct D1 adapter. Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows.",
"version": "1.0.0",
"author": {
"name": "Jeremy Dawes",
"email": "jeremy@jezweb.net"
},
"license": "MIT",
"repository": "https://github.com/jezweb/claude-skills",
"keywords": []
}
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 Skill
Production-ready authentication for TypeScript with Cloudflare D1 support
---
What This Skill Does
Provides complete patterns for implementing authentication with better-auth, a comprehensive TypeScript auth framework. Includes support for Cloudflare Workers + D1 via Drizzle ORM or Kysely (no direct D1 adapter exists), making it an excellent self-hosted alternative to Clerk or Auth.js.
⚠️ v2.0.0 Breaking Change: Previous skill version incorrectly documented a non-existent d1Adapter(). This version corrects all patterns to use Drizzle ORM or Kysely as required by better-auth.
---
Auto-Trigger Keywords
This skill should be automatically invoked when you mention:
- "better-auth" - The library name
- "authentication with D1" - Cloudflare D1 auth setup
- "self-hosted auth" - Alternative to managed services
- "alternative to Clerk" - Migration or comparison
- "alternative to Auth.js" - Upgrading from Auth.js
- "TypeScript authentication" - Type-safe auth
- "better auth setup" - Initial configuration
- "social auth with Cloudflare" - OAuth on Workers
- "D1 authentication" - Database-backed auth on D1
- "multi-tenant auth" - SaaS authentication patterns
- "organization auth" - Team/org features
- "2FA authentication" - Two-factor auth setup
- "passkeys" - Passwordless auth
- "magic link auth" - Email-based passwordless
- "better-auth endpoints" - Auto-generated REST endpoints
- "better-auth API" - Server-side API methods
- "auth.api methods" - Programmatic auth operations
- "TanStack Start auth" - TanStack Start integration
- "reactStartCookies" - TanStack Start cookie plugin
- "multi-session" - Account switching
- "genericOAuth" - Custom OAuth providers
- "API key authentication" - API-only auth
- "TanStack Query session" - Session state with React Query
- "nanostores auth" - Nanostore session invalidation
---
When to Use This Skill
✅ Use this skill when:
- Building authentication for Cloudflare Workers + D1 applications
- Need a self-hosted, vendor-independent auth solution
- Migrating from Clerk to avoid vendor lock-in and costs
- Upgrading from Auth.js to get more features (2FA, organizations, RBAC)
- Implementing multi-tenant SaaS with organizations/teams
- Require advanced features: 2FA, passkeys, social auth, rate limiting
- Want full control over auth logic and data
❌ Don't use this skill when:
- You're happy with Clerk and don't mind the cost
- Using Firebase Auth (different ecosystem)
- Building a simple prototype (Auth.js may be faster)
- Auth requirements are extremely basic (custom JWT might suffice)
---
What You'll Get
Patterns Included
1. Cloudflare Workers + D1 - Complete Worker setup with D1 adapter 2. Framework Integrations - TanStack Start (reactStartCookies), Expo 3. React Client Integration - Hooks and components for auth state 4. Protected Routes - Middleware patterns for session verification 5. Social Providers - Google, GitHub, Microsoft OAuth setup + custom OAuth 6. Advanced Features - 2FA, organizations, multi-tenant, multi-session, API keys 7. Migration Guides - From Clerk and Auth.js 8. Database Setup - D1 and PostgreSQL schema patterns 9. API Reference - Complete documentation for 80+ auto-generated endpoints
Errors Prevented (13 Common Issues)
- ✅ D1 adapter misconfiguration (no direct d1Adapter, must use Drizzle/Kysely)
- ✅ Schema generation failures (using Drizzle Kit correctly)
- ✅ TanStack Start cookie issues (reactStartCookies plugin required)
- ✅ Plugin ordering errors (reactStartCookies must be last)
- ✅ Nanostore session invalidation (TanStack Query won't refresh session state)
- ✅ D1 eventual consistency causing stale session reads
- ✅ CORS misconfiguration for SPA applications
- ✅ Session serialization errors in Workers
- ✅ OAuth redirect URI mismatch
- ✅ Email verification not sending
- ✅ JWT token expiration issues
- ✅ Social provider scope issues (missing user data)
- ✅ TypeScript errors with Drizzle schema
Reference Files
- `scripts/setup-d1.sh` - Automated D1 database setup
- `references/cloudflare-worker-example.ts` - Complete Worker implementation
- `references/nextjs-api-route.ts` - Next.js patterns
- `references/react-client-hooks.tsx` - React components
- `references/drizzle-schema.ts` - Database schema
- `assets/auth-flow-diagram.md` - Visual flow diagrams
---
Quick Example
Cloudflare Worker Setup (Drizzle ORM)
⚠️ CRITICAL: better-auth requires Drizzle ORM or Kysely for D1. There is NO direct d1Adapter().
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { drizzle } from 'drizzle-orm/d1'
import { Hono } from 'hono'
import * as schema from './db/schema' // Your Drizzle schema
type Env = {
DB: D1Database
BETTER_AUTH_SECRET: string
GOOGLE_CLIENT_ID: string
GOOGLE_CLIENT_SECRET: string
}
const app = new Hono<{ Bindings: Env }>()
app.all('/api/auth/*', async (c) => {
// Initialize Drizzle with D1
const db = drizzle(c.env.DB, { schema })
const auth = betterAuth({
// Use Drizzle adapter with SQLite provider
database: drizzleAdapter(db, {
provider: "sqlite",
}),
secret: c.env.BETTER_AUTH_SECRET,
emailAndPassword: { enabled: true },
socialProviders: {
google: {
clientId: c.env.GOOGLE_CLIENT_ID,
clientSecret: c.env.GOOGLE_CLIENT_SECRET
}
}
})
return auth.handler(c.req.raw)
})
export default appRequired dependencies:
npm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types honoComplete setup guide: See SKILL.md for full step-by-step instructions including schema definition, migrations, and deployment.
---
Performance
- Token Savings: ~75% (28k → 7k tokens)
- Time Savings: ~97% reduction (220 hours manual → 4-8 hours with better-auth)
- Error Prevention: 16 documented issues with solutions
- API Coverage: Complete reference for 80+ auto-generated endpoints
- Plugin Documentation: TanStack Start, multiSession, genericOAuth, apiKey + 5 more
---
Comparison to Alternatives
| Feature | better-auth | Clerk | Auth.js |
|---|---|---|---|
| Hosting | Self-hosted | Third-party | Self-hosted |
| Cost | Free | $25/mo+ | Free |
| Cloudflare D1 | ✅ First-class | ❌ No | ✅ Adapter |
| 2FA/Passkeys | ✅ Plugin | ✅ Built-in | ⚠️ Limited |
| Organizations | ✅ Plugin | ✅ Built-in | ❌ No |
| Vendor Lock-in | ✅ None | ❌ High | ✅ None |
---
Production Tested
- Projects: 4 verified D1 production repos
- zpg6/better-auth-cloudflare (Drizzle + D1)
- zwily/example-react-router-cloudflare-d1-drizzle-better-auth
- foxlau/react-router-v7-better-auth (Drizzle + D1)
- matthewlynch/better-auth-react-router-cloudflare-d1 (Kysely + D1)
---
Official Resources
- Docs: https://better-auth.com
- GitHub: https://github.com/better-auth/better-auth (22.4k ⭐)
- Package:
better-auth@1.3.34 - Examples: https://github.com/better-auth/better-auth/tree/main/examples
---
Installation
npm install better-auth
# or
pnpm add better-auth
# or
yarn add better-authFor Cloudflare D1:
npm install @cloudflare/workers-typesFor PostgreSQL:
npm install pg drizzle-orm---
Version Info
- Skill Version: 2.2.0 (Added TanStack Start, multiSession, genericOAuth, apiKey + 50+ documentation links)
- Package Version: better-auth@1.3.34
- Drizzle ORM: drizzle-orm@0.44.7, drizzle-kit@0.31.6
- Kysely: kysely@0.28.8, kysely-d1@0.4.0
- Last Verified: 2025-11-18
- Compatibility: Node.js 18+, Bun 1.0+, Cloudflare Workers
---
License
MIT (same as better-auth)
---
Questions? Check the official docs or ask Claude Code to invoke this skill!
/**
* 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 "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 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.
*
* ═══════════════════════════════════════════════════════════════
*/
/**
* 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)
* });
*
* ═══════════════════════════════════════════════════════════════
*/
/**
* 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
/**
* React Client Components with better-auth
*
* This example demonstrates:
* - useSession hook
* - Sign in/up forms
* - Social sign-in buttons
* - Protected route component
* - User profile component
* - Organization switcher
*/
'use client'
import { createAuthClient, useSession } from 'better-auth/client'
import { useState, useEffect } from 'react'
// Initialize auth client
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
})
// ============================================================================
// Login Form Component
// ============================================================================
export function LoginForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleEmailSignIn = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const { data, error } = await authClient.signIn.email({
email,
password
})
if (error) {
setError(error.message)
return
}
// Redirect on success
window.location.href = '/dashboard'
} catch (err) {
setError('An error occurred. Please try again.')
} finally {
setLoading(false)
}
}
const handleGoogleSignIn = async () => {
setLoading(true)
await authClient.signIn.social({
provider: 'google',
callbackURL: '/dashboard'
})
}
const handleGitHubSignIn = async () => {
setLoading(true)
await authClient.signIn.social({
provider: 'github',
callbackURL: '/dashboard'
})
}
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
<h2 className="text-2xl font-bold mb-6">Sign In</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
<form onSubmit={handleEmailSignIn} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border rounded-md"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border rounded-md"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3">
<button
onClick={handleGoogleSignIn}
disabled={loading}
className="py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50"
>
Google
</button>
<button
onClick={handleGitHubSignIn}
disabled={loading}
className="py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50"
>
GitHub
</button>
</div>
</div>
<p className="mt-4 text-center text-sm text-gray-600">
Don't have an account?{' '}
<a href="/signup" className="text-blue-600 hover:underline">
Sign up
</a>
</p>
</div>
)
}
// ============================================================================
// Sign Up Form Component
// ============================================================================
export function SignUpForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const { data, error } = await authClient.signUp.email({
email,
password,
name
})
if (error) {
setError(error.message)
return
}
setSuccess(true)
} catch (err) {
setError('An error occurred. Please try again.')
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
<h2 className="text-2xl font-bold mb-4">Check your email</h2>
<p className="text-gray-600">
We've sent a verification link to <strong>{email}</strong>.
Click the link to verify your account.
</p>
</div>
)
}
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
<h2 className="text-2xl font-bold mb-6">Sign Up</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
</div>
)}
<form onSubmit={handleSignUp} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full px-3 py-2 border rounded-md"
placeholder="John Doe"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border rounded-md"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 border rounded-md"
placeholder="••••••••"
/>
<p className="mt-1 text-xs text-gray-500">
At least 8 characters
</p>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-600">
Already have an account?{' '}
<a href="/login" className="text-blue-600 hover:underline">
Sign in
</a>
</p>
</div>
)
}
// ============================================================================
// User Profile Component
// ============================================================================
export function UserProfile() {
const { data: session, isPending } = useSession()
if (isPending) {
return <div className="p-4">Loading...</div>
}
if (!session) {
return (
<div className="p-4">
<p>Not authenticated</p>
<a href="/login" className="text-blue-600 hover:underline">
Sign in
</a>
</div>
)
}
const handleSignOut = async () => {
await authClient.signOut()
window.location.href = '/login'
}
return (
<div className="p-4 bg-white rounded-lg shadow">
<div className="flex items-center gap-4">
{session.user.image && (
<img
src={session.user.image}
alt={session.user.name || 'User'}
className="w-12 h-12 rounded-full"
/>
)}
<div className="flex-1">
<h3 className="font-semibold">{session.user.name}</h3>
<p className="text-sm text-gray-600">{session.user.email}</p>
</div>
<button
onClick={handleSignOut}
className="px-4 py-2 text-sm border rounded-md hover:bg-gray-50"
>
Sign Out
</button>
</div>
</div>
)
}
// ============================================================================
// Protected Route Component
// ============================================================================
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { data: session, isPending } = useSession()
if (isPending) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
)
}
if (!session) {
// Redirect to login
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
return null
}
return <>{children}</>
}
// ============================================================================
// Organization Switcher Component (if using organizations plugin)
// ============================================================================
export function OrganizationSwitcher() {
const { data: session } = useSession()
const [organizations, setOrganizations] = useState([])
const [loading, setLoading] = useState(true)
// Fetch user's organizations
useEffect(() => {
async function fetchOrgs() {
const orgs = await authClient.organization.listUserOrganizations()
setOrganizations(orgs)
setLoading(false)
}
fetchOrgs()
}, [])
const switchOrganization = async (orgId: string) => {
await authClient.organization.setActiveOrganization({ organizationId: orgId })
window.location.reload()
}
if (loading) return <div>Loading organizations...</div>
return (
<select
onChange={(e) => switchOrganization(e.target.value)}
className="px-3 py-2 border rounded-md"
>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
)
}
// ============================================================================
// 2FA Setup Component (if using twoFactor plugin)
// ============================================================================
export function TwoFactorSetup() {
const [qrCode, setQrCode] = useState('')
const [verifyCode, setVerifyCode] = useState('')
const [enabled, setEnabled] = useState(false)
const enable2FA = async () => {
const { data } = await authClient.twoFactor.enable({ method: 'totp' })
setQrCode(data.qrCode)
}
const verify2FA = async (e: React.FormEvent) => {
e.preventDefault()
const { error } = await authClient.twoFactor.verify({ code: verifyCode })
if (!error) {
setEnabled(true)
}
}
if (enabled) {
return <div className="p-4 bg-green-100 rounded">2FA is enabled!</div>
}
if (qrCode) {
return (
<div className="p-4 bg-white rounded-lg shadow">
<h3 className="font-semibold mb-4">Scan QR Code</h3>
<img src={qrCode} alt="2FA QR Code" className="mx-auto mb-4" />
<form onSubmit={verify2FA} className="space-y-4">
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="Enter 6-digit code"
className="w-full px-3 py-2 border rounded-md"
maxLength={6}
/>
<button
type="submit"
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md"
>
Verify & Enable
</button>
</form>
</div>
)
}
return (
<button
onClick={enable2FA}
className="px-4 py-2 bg-blue-600 text-white rounded-md"
>
Enable 2FA
</button>
)
}