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. v1.4.0 (Nov 2025) adds stateless sessions, ESM-only (breaking), JWT key rotation, SCIM provisioning. v1.3 adds SSO/SAML, multi-team support. Use when: self-hosting auth on Cloudflare D1, migrating from Clerk, implementing multi-tenant SaaS, or troubleshooting D1 adapter errors, session serialization, OAuth flows, TanStack Start cookie issues, nanostore session invalidation.
Package: [email protected] (Nov 22, 2025) Breaking Changes: ESM-only (v1.4.0), Multi-team table changes (v1.3), D1 requires Drizzle/Kysely (no direct adapter)
better-auth DOES NOT have d1Adapter(). You MUST use:
drizzleAdapter(db, { provider: "sqlite" })new Kysely({ dialect: new D1Dialect({ database: env.DB }) })See Issue #1 below for details.
Major Features:
Major Features:
@better-auth/sso package)teamId removed from member table, new teamMembers table requiredIf 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.
⚠️ 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
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 | 📚 |
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:
auth.api.* methodsTime savings: Building this from scratch = ~220 hours. With better-auth = ~4-8 hours. 97% reduction.
All endpoints are automatically exposed at /api/auth/* when using auth.handler().
| 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 |
| 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
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 |
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 |
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 |
Passkey Plugin (5 endpoints) - Docs:
/passkey/add, /sign-in/passkey, /passkey/list, /passkey/delete, /passkey/updateMagic Link Plugin (2 endpoints) - Docs:
/sign-in/magic-link, /magic-link/verifyUsername Plugin (2 endpoints) - Docs:
/sign-in/username, /username/is-availablePhone Number Plugin (5 endpoints) - Docs:
/sign-in/phone-number, /phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-passwordEmail 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-passwordAnonymous Plugin (1 endpoint) - Docs:
/sign-in/anonymousJWT 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)auth.api.*)Every HTTP endpoint has a corresponding server-side method. Use these for:
// 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: "[email protected]" },
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,
});
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,
});
| 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 });
});
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:
Programmatic access:
const schema = await auth.api.generateOpenAPISchema();
console.log(JSON.stringify(schema, null, 2));
// Returns full OpenAPI 3.0 spec
Building from scratch (manual implementation):
Total manual effort: ~220 hours (5.5 weeks full-time)
With better-auth:
Total with better-auth: 4-8 hours
Savings: ~97% development time
better-auth provides 80+ production-ready endpoints covering:
You write zero endpoint code. Just configure features and call auth.handler().
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'