Claude Code Plugins

Community-maintained marketplace

Feedback

|

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name better-auth
description Production-ready authentication framework for TypeScript with Cloudflare D1 support via Drizzle ORM or Kysely. Use this skill when building auth systems as a self-hosted alternative to Clerk or Auth.js, particularly for Cloudflare Workers projects. CRITICAL: better-auth requires Drizzle ORM or Kysely as database adapters - there is NO direct D1 adapter. Supports social providers (Google, GitHub, Microsoft, Apple), email/password, magic links, 2FA, passkeys, organizations, and RBAC. Prevents 12+ common authentication errors including D1 adapter misconfiguration, schema generation issues, session serialization, CORS, OAuth flows, and JWT token handling. Keywords: better-auth, authentication, cloudflare d1 auth, drizzle orm auth, kysely auth, self-hosted auth, typescript auth, clerk alternative, auth.js alternative, social login, oauth providers, session management, jwt tokens, 2fa, two-factor, passkeys, webauthn, multi-tenant auth, organizations, teams, rbac, role-based access, google auth, github auth, microsoft auth, apple auth, magic links, email password, better-auth setup, drizzle d1, kysely d1, session serialization error, cors auth, d1 adapter
license MIT
metadata [object Object]
allowed-tools Read, Write, Edit, Bash, Glob, Grep

better-auth Skill

Overview

better-auth is a comprehensive, framework-agnostic authentication and authorization library for TypeScript. It provides a complete auth solution with support for Cloudflare D1 via Drizzle ORM or Kysely, making it an excellent self-hosted alternative to Clerk or Auth.js.

⚠️ CRITICAL: D1 Adapter Requirements

better-auth DOES NOT have a direct d1Adapter(). You MUST use either:

  1. Drizzle ORM (recommended) - drizzleAdapter()
  2. Kysely (alternative) - Kysely instance with D1Dialect

Use this skill when:

  • Building authentication for Cloudflare Workers + D1 applications
  • Need a self-hosted, vendor-independent auth solution
  • Migrating from Clerk (avoid vendor lock-in and costs)
  • Upgrading from Auth.js (need more features like 2FA, organizations)
  • Implementing multi-tenant SaaS with organizations/teams
  • Require advanced features: 2FA, passkeys, RBAC, social auth, rate limiting

Package: better-auth@1.3.34 (latest stable verified 2025-11-08)


Installation

Core Packages

Option 1: Drizzle ORM (Recommended)

npm install better-auth drizzle-orm drizzle-kit
# or
pnpm add better-auth drizzle-orm drizzle-kit

Option 2: Kysely

npm install better-auth kysely kysely-d1
# or
pnpm add better-auth kysely kysely-d1

Additional Dependencies

For Cloudflare Workers:

npm install @cloudflare/workers-types hono

For PostgreSQL (via Hyperdrive):

npm install pg drizzle-orm
# or with Kysely
npm install kysely

Social Providers (Optional):

npm install @better-auth/google
npm install @better-auth/github
npm install @better-auth/microsoft

Quick Start: Cloudflare Workers + D1 + Drizzle

Step 1: Create D1 Database

# Create database
wrangler d1 create my-app-db

# Copy the database_id from output

Add to wrangler.toml:

name = "my-app"
compatibility_date = "2024-11-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-database-id-here"

[vars]
BETTER_AUTH_URL = "http://localhost:5173"

# Secrets (use: wrangler secret put SECRET_NAME)
# - BETTER_AUTH_SECRET
# - GOOGLE_CLIENT_ID
# - GOOGLE_CLIENT_SECRET

Step 2: Define Database Schema

File: src/db/schema.ts

import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";

// better-auth core tables
export const user = sqliteTable("user", {
  id: text().primaryKey(),
  name: text().notNull(),
  email: text().notNull().unique(),
  emailVerified: integer({ mode: "boolean" }).notNull().default(false),
  image: text(),
  createdAt: integer({ mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
  updatedAt: integer({ mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
});

export const session = sqliteTable("session", {
  id: text().primaryKey(),
  userId: text()
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  token: text().notNull(),
  expiresAt: integer({ mode: "timestamp" }).notNull(),
  ipAddress: text(),
  userAgent: text(),
  createdAt: integer({ mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
  updatedAt: integer({ mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
});

export const account = sqliteTable("account", {
  id: text().primaryKey(),
  userId: text()
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  accountId: text().notNull(),
  providerId: text().notNull(),
  accessToken: text(),
  refreshToken: text(),
  accessTokenExpiresAt: integer({ mode: "timestamp" }),
  refreshTokenExpiresAt: integer({ mode: "timestamp" }),
  scope: text(),
  idToken: text(),
  password: text(),
  createdAt: integer({ mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
  updatedAt: integer({ mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
});

export const verification = sqliteTable("verification", {
  id: text().primaryKey(),
  identifier: text().notNull(),
  value: text().notNull(),
  expiresAt: integer({ mode: "timestamp" }).notNull(),
  createdAt: integer({ mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
  updatedAt: integer({ mode: "timestamp" })
    .notNull()
    .default(sql`(unixepoch())`),
});

// Add your custom tables here
export const profile = sqliteTable("profile", {
  id: text().primaryKey(),
  userId: text()
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  bio: text(),
  website: text(),
});

Step 3: Configure Drizzle

File: drizzle.config.ts

import type { Config } from "drizzle-kit";

export default {
  out: "./drizzle",
  schema: "./src/db/schema.ts",
  dialect: "sqlite",
  driver: "d1-http",
  dbCredentials: {
    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    token: process.env.CLOUDFLARE_TOKEN!,
  },
} satisfies Config;

Create .env file (for migrations):

CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_DATABASE_ID=your-database-id
CLOUDFLARE_TOKEN=your-api-token

Step 4: Generate and Apply Migrations

# Generate migration from schema
npx drizzle-kit generate

# Apply migration to D1 (local)
wrangler d1 migrations apply my-app-db --local

# Apply migration to D1 (production)
wrangler d1 migrations apply my-app-db --remote

Step 5: Initialize Database and Auth

File: src/db/index.ts

import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";

export type Database = ReturnType<typeof createDatabase>;

export function createDatabase(d1: D1Database) {
  return drizzle(d1, { schema });
}

File: src/auth.ts

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { Database } from "./db";

type Env = {
  DB: D1Database;
  BETTER_AUTH_SECRET: string;
  BETTER_AUTH_URL: string;
  GOOGLE_CLIENT_ID?: string;
  GOOGLE_CLIENT_SECRET?: string;
  GITHUB_CLIENT_ID?: string;
  GITHUB_CLIENT_SECRET?: string;
};

export function createAuth(db: Database, env: Env) {
  return betterAuth({
    baseURL: env.BETTER_AUTH_URL,
    secret: env.BETTER_AUTH_SECRET,

    // Drizzle adapter with SQLite provider
    database: drizzleAdapter(db, {
      provider: "sqlite",
    }),

    // Email/password authentication
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: true,
      sendVerificationEmail: async ({ user, url, token }) => {
        // TODO: Implement email sending
        // Use Resend, SendGrid, or Cloudflare Email Routing
        console.log(`Verification email for ${user.email}: ${url}`);
      },
    },

    // Social providers
    socialProviders: {
      google: env.GOOGLE_CLIENT_ID
        ? {
            clientId: env.GOOGLE_CLIENT_ID,
            clientSecret: env.GOOGLE_CLIENT_SECRET!,
            scope: ["openid", "email", "profile"],
          }
        : undefined,
      github: env.GITHUB_CLIENT_ID
        ? {
            clientId: env.GITHUB_CLIENT_ID,
            clientSecret: env.GITHUB_CLIENT_SECRET!,
            scope: ["user:email", "read:user"],
          }
        : undefined,
    },

    // Session configuration
    session: {
      expiresIn: 60 * 60 * 24 * 7, // 7 days
      updateAge: 60 * 60 * 24, // Update every 24 hours
    },
  });
}

Step 6: Create Worker with Auth Routes

File: src/index.ts

import { Hono } from "hono";
import { cors } from "hono/cors";
import { createDatabase } from "./db";
import { createAuth } from "./auth";

type Env = {
  DB: D1Database;
  BETTER_AUTH_SECRET: string;
  BETTER_AUTH_URL: string;
  GOOGLE_CLIENT_ID?: string;
  GOOGLE_CLIENT_SECRET?: string;
  GITHUB_CLIENT_ID?: string;
  GITHUB_CLIENT_SECRET?: string;
};

const app = new Hono<{ Bindings: Env }>();

// CORS for frontend
app.use(
  "/api/*",
  cors({
    origin: ["http://localhost:3000", "https://yourdomain.com"],
    credentials: true,
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allowHeaders: ["Content-Type", "Authorization"],
  })
);

// Auth routes - handle all better-auth endpoints
app.all("/api/auth/*", async (c) => {
  const db = createDatabase(c.env.DB);
  const auth = createAuth(db, c.env);
  return auth.handler(c.req.raw);
});

// Example: Protected API route
app.get("/api/protected", async (c) => {
  const db = createDatabase(c.env.DB);
  const auth = createAuth(db, c.env);
  const session = await auth.api.getSession({
    headers: c.req.raw.headers,
  });

  if (!session) {
    return c.json({ error: "Unauthorized" }, 401);
  }

  return c.json({
    message: "Protected data",
    user: session.user,
  });
});

// Health check
app.get("/health", (c) => c.json({ status: "ok" }));

export default app;

Step 7: Set Secrets

# Generate a random secret
openssl rand -base64 32

# Set secrets in Wrangler
wrangler secret put BETTER_AUTH_SECRET
# Paste the generated secret

# Optional: Set OAuth secrets
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRET

Step 8: Deploy

# Test locally
npm run dev

# Deploy to Cloudflare
wrangler deploy

Alternative: Kysely Adapter Pattern

If you prefer Kysely over Drizzle:

File: src/auth.ts

import { betterAuth } from "better-auth";
import { Kysely, CamelCasePlugin } from "kysely";
import { D1Dialect } from "kysely-d1";

type Env = {
  DB: D1Database;
  BETTER_AUTH_SECRET: string;
  // ... other env vars
};

export function createAuth(env: Env) {
  return betterAuth({
    secret: env.BETTER_AUTH_SECRET,

    // Kysely with D1Dialect
    database: {
      db: new Kysely({
        dialect: new D1Dialect({
          database: env.DB,
        }),
        plugins: [
          // CRITICAL: Required if using Drizzle schema with snake_case
          new CamelCasePlugin(),
        ],
      }),
      type: "sqlite",
    },

    emailAndPassword: {
      enabled: true,
    },

    // ... other config
  });
}

Why CamelCasePlugin?

If your Drizzle schema uses snake_case column names (e.g., email_verified), but better-auth expects camelCase (e.g., emailVerified), the CamelCasePlugin automatically converts between the two.


Client Integration (React)

File: src/lib/auth-client.ts

import { createAuthClient } from "better-auth/client";

export const authClient = createAuthClient({
  baseURL: import.meta.env.VITE_API_URL || "http://localhost:8787",
});

File: src/components/LoginForm.tsx

"use client";

import { authClient } from "@/lib/auth-client";
import { useState } from "react";

export function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    const { data, error } = await authClient.signIn.email({
      email,
      password,
    });

    if (error) {
      console.error("Login failed:", error);
      return;
    }

    window.location.href = "/dashboard";
  };

  const handleGoogleSignIn = async () => {
    await authClient.signIn.social({
      provider: "google",
      callbackURL: "/dashboard",
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Sign In</button>
      <button type="button" onClick={handleGoogleSignIn}>
        Sign in with Google
      </button>
    </form>
  );
}

Use React Hook:

"use client";

import { authClient } from "@/lib/auth-client";

export function UserProfile() {
  const { data: session, isPending } = authClient.useSession();

  if (isPending) return <div>Loading...</div>;
  if (!session) return <div>Not authenticated</div>;

  return (
    <div>
      <p>Welcome, {session.user.email}</p>
      <button onClick={() => authClient.signOut()}>Sign Out</button>
    </div>
  );
}

Advanced Features

Two-Factor Authentication (2FA)

import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";

export const auth = betterAuth({
  database: /* ... */,
  plugins: [
    twoFactor({
      methods: ["totp", "sms"],
      issuer: "MyApp",
    }),
  ],
});

Client:

// Enable 2FA
const { data, error } = await authClient.twoFactor.enable({
  method: "totp",
});

// Verify code
await authClient.twoFactor.verify({
  code: "123456",
});

Organizations & Teams

import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins";

export const auth = betterAuth({
  database: /* ... */,
  plugins: [
    organization({
      roles: ["owner", "admin", "member"],
      permissions: {
        admin: ["read", "write", "delete"],
        member: ["read"],
      },
    }),
  ],
});

Client:

// Create organization
await authClient.organization.create({
  name: "Acme Corp",
  slug: "acme",
});

// Invite member
await authClient.organization.inviteMember({
  organizationId: "org_123",
  email: "user@example.com",
  role: "member",
});

// Check permissions
const canDelete = await authClient.organization.hasPermission({
  organizationId: "org_123",
  permission: "delete",
});

Rate Limiting with KV

import { betterAuth } from "better-auth";
import { rateLimit } from "better-auth/plugins";

type Env = {
  DB: D1Database;
  RATE_LIMIT_KV: KVNamespace;
  // ...
};

export function createAuth(db: Database, env: Env) {
  return betterAuth({
    database: drizzleAdapter(db, { provider: "sqlite" }),
    plugins: [
      rateLimit({
        window: 60, // 60 seconds
        max: 10, // 10 requests per window
        storage: {
          get: async (key) => {
            return await env.RATE_LIMIT_KV.get(key);
          },
          set: async (key, value, ttl) => {
            await env.RATE_LIMIT_KV.put(key, value, {
              expirationTtl: ttl,
            });
          },
        },
      }),
    ],
  });
}

Known Issues & Solutions

Issue 1: "d1Adapter is not exported" Error

Problem: Code shows import { d1Adapter } from 'better-auth/adapters/d1' but this doesn't exist.

Symptoms: TypeScript error or runtime error about missing export.

Solution: Use Drizzle or Kysely instead:

// ❌ WRONG - This doesn't exist
import { d1Adapter } from 'better-auth/adapters/d1'
database: d1Adapter(env.DB)

// ✅ CORRECT - Use Drizzle
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { drizzle } from 'drizzle-orm/d1'
const db = drizzle(env.DB, { schema })
database: drizzleAdapter(db, { provider: "sqlite" })

// ✅ CORRECT - Use Kysely
import { Kysely } from 'kysely'
import { D1Dialect } from 'kysely-d1'
database: {
  db: new Kysely({ dialect: new D1Dialect({ database: env.DB }) }),
  type: "sqlite"
}

Source: Verified from 4 production repositories using better-auth + D1


Issue 2: Schema Generation Fails

Problem: npx better-auth migrate doesn't create D1-compatible schema.

Symptoms: Migration SQL has wrong syntax or doesn't work with D1.

Solution: Use Drizzle Kit to generate migrations:

# Generate migration from Drizzle schema
npx drizzle-kit generate

# Apply to D1
wrangler d1 migrations apply my-app-db --remote

Why: Drizzle Kit generates SQLite-compatible SQL that works with D1.


Issue 3: "CamelCase" vs "snake_case" Column Mismatch

Problem: Database has email_verified but better-auth expects emailVerified.

Symptoms: Session reads fail, user data missing fields.

Solution: Use CamelCasePlugin with Kysely or configure Drizzle properly:

With Kysely:

import { CamelCasePlugin } from "kysely";

new Kysely({
  dialect: new D1Dialect({ database: env.DB }),
  plugins: [new CamelCasePlugin()], // Converts between naming conventions
})

With Drizzle: Define schema with camelCase from the start (as shown in examples).


Issue 4: D1 Eventual Consistency

Problem: Session reads immediately after write return stale data.

Symptoms: User logs in but getSession() returns null on next request.

Solution: Use Cloudflare KV for session storage (strong consistency):

import { betterAuth } from "better-auth";

export function createAuth(db: Database, env: Env) {
  return betterAuth({
    database: drizzleAdapter(db, { provider: "sqlite" }),
    session: {
      storage: {
        get: async (sessionId) => {
          const session = await env.SESSIONS_KV.get(sessionId);
          return session ? JSON.parse(session) : null;
        },
        set: async (sessionId, session, ttl) => {
          await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), {
            expirationTtl: ttl,
          });
        },
        delete: async (sessionId) => {
          await env.SESSIONS_KV.delete(sessionId);
        },
      },
    },
  });
}

Add to wrangler.toml:

[[kv_namespaces]]
binding = "SESSIONS_KV"
id = "your-kv-namespace-id"

Issue 5: CORS Errors for SPA Applications

Problem: CORS errors when auth API is on different origin than frontend.

Symptoms: Access-Control-Allow-Origin errors in browser console.

Solution: Configure CORS headers in Worker:

import { cors } from "hono/cors";

app.use(
  "/api/auth/*",
  cors({
    origin: ["https://yourdomain.com", "http://localhost:3000"],
    credentials: true, // Allow cookies
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  })
);

Issue 6: OAuth Redirect URI Mismatch

Problem: Social sign-in fails with "redirect_uri_mismatch" error.

Symptoms: Google/GitHub OAuth returns error after user consent.

Solution: Ensure exact match in OAuth provider settings:

Provider setting: https://yourdomain.com/api/auth/callback/google
better-auth URL:  https://yourdomain.com/api/auth/callback/google

❌ Wrong: http vs https, trailing slash, subdomain mismatch
✅ Right: Exact character-for-character match

Check better-auth callback URL:

// It's always: {baseURL}/api/auth/callback/{provider}
const callbackURL = `${env.BETTER_AUTH_URL}/api/auth/callback/google`;
console.log("Configure this URL in Google Console:", callbackURL);

Issue 7: Missing Dependencies

Problem: TypeScript errors or runtime errors about missing packages.

Symptoms: Cannot find module 'drizzle-orm' or similar.

Solution: Install all required packages:

For Drizzle approach:

npm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types

For Kysely approach:

npm install better-auth kysely kysely-d1 @cloudflare/workers-types

Issue 8: Email Verification Not Sending

Problem: Email verification links never arrive.

Symptoms: User signs up, but no email received.

Solution: Implement sendVerificationEmail handler:

export const auth = betterAuth({
  database: /* ... */,
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendVerificationEmail: async ({ user, url, token }) => {
      // Use your email service (SendGrid, Resend, etc.)
      await sendEmail({
        to: user.email,
        subject: "Verify your email",
        html: `
          <p>Click the link below to verify your email:</p>
          <a href="${url}">Verify Email</a>
          <p>Or use this code: ${token}</p>
        `,
      });
    },
  },
});

For Cloudflare: Use Cloudflare Email Routing or external service (Resend, SendGrid).


Issue 9: Session Expires Too Quickly

Problem: Session expires unexpectedly or never expires.

Symptoms: User logged out unexpectedly or session persists after logout.

Solution: Configure session expiration:

export const auth = betterAuth({
  database: /* ... */,
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days (in seconds)
    updateAge: 60 * 60 * 24, // Update session every 24 hours
  },
});

Issue 10: Social Provider Missing User Data

Problem: Social sign-in succeeds but missing user data (name, avatar).

Symptoms: session.user.name is null after Google/GitHub sign-in.

Solution: Request additional scopes:

socialProviders: {
  google: {
    clientId: env.GOOGLE_CLIENT_ID,
    clientSecret: env.GOOGLE_CLIENT_SECRET,
    scope: ["openid", "email", "profile"], // Include 'profile' for name/image
  },
  github: {
    clientId: env.GITHUB_CLIENT_ID,
    clientSecret: env.GITHUB_CLIENT_SECRET,
    scope: ["user:email", "read:user"], // 'read:user' for full profile
  },
}

Issue 11: TypeScript Errors with Drizzle Schema

Problem: TypeScript complains about schema types.

Symptoms: Type 'DrizzleD1Database' is not assignable to...

Solution: Export proper types from database:

// src/db/index.ts
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "./schema";

export type Database = DrizzleD1Database<typeof schema>;

export function createDatabase(d1: D1Database): Database {
  return drizzle(d1, { schema });
}

Issue 12: Wrangler Dev Mode Not Working

Problem: wrangler dev fails with database errors.

Symptoms: "Database not found" or migration errors in local dev.

Solution: Apply migrations locally first:

# Apply migrations to local D1
wrangler d1 migrations apply my-app-db --local

# Then run dev server
wrangler dev

Comparison: better-auth vs Alternatives

Feature better-auth Clerk Auth.js
Hosting Self-hosted Third-party Self-hosted
Cost Free (OSS) $25/mo+ Free (OSS)
Cloudflare D1 ✅ Drizzle/Kysely ❌ No ✅ Adapter
Social Auth ✅ 10+ providers ✅ Many ✅ Many
2FA/Passkeys ✅ Plugin ✅ Built-in ⚠️ Limited
Organizations ✅ Plugin ✅ Built-in ❌ No
Multi-tenant ✅ Plugin ✅ Yes ❌ No
RBAC ✅ Plugin ✅ Yes ⚠️ Custom
Magic Links ✅ Built-in ✅ Yes ✅ Yes
Email/Password ✅ Built-in ✅ Yes ✅ Yes
Session Mgmt ✅ JWT + DB ✅ JWT ✅ JWT + DB
TypeScript ✅ First-class ✅ Yes ✅ Yes
Framework Support ✅ Agnostic ⚠️ React-focused ✅ Agnostic
Vendor Lock-in ✅ None ❌ High ✅ None
Customization ✅ Full control ⚠️ Limited ✅ Full control
Production Ready ✅ Yes ✅ Yes ✅ Yes

Recommendation:

  • Use better-auth if: Self-hosted, Cloudflare D1, want full control, avoid vendor lock-in
  • Use Clerk if: Want managed service, don't mind cost, need fastest setup
  • Use Auth.js if: Already using Next.js, basic needs, familiar with it

Migration Guides

From Clerk

Key differences:

  • Clerk: Third-party service → better-auth: Self-hosted
  • Clerk: Proprietary → better-auth: Open source
  • Clerk: Monthly cost → better-auth: Free

Migration steps:

  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();
    

Best Practices

Security

  1. Always use HTTPS in production (no exceptions)
  2. Rotate secrets regularly:
    openssl rand -base64 32
    wrangler secret put BETTER_AUTH_SECRET
    
  3. Validate email domains for sign-up:
    emailAndPassword: {
      enabled: true,
      validate: async (email) => {
        const blockedDomains = ["tempmail.com", "guerrillamail.com"];
        const domain = email.split("@")[1];
        if (blockedDomains.includes(domain)) {
          throw new Error("Email domain not allowed");
        }
      },
    };
    
  4. Enable rate limiting for auth endpoints
  5. Log auth events for security monitoring

Performance

  1. Cache session lookups (use KV for Workers)
  2. Use indexes on frequently queried fields:
    CREATE INDEX idx_sessions_user_id ON session(userId);
    CREATE INDEX idx_accounts_provider ON account(providerId, accountId);
    
  3. Minimize session data (only essential fields)

Development Workflow

  1. Use environment-specific configs:

    const isDev = process.env.NODE_ENV === "development";
    
    export const auth = betterAuth({
      baseURL: isDev ? "http://localhost:3000" : "https://yourdomain.com",
      session: {
        expiresIn: isDev
          ? 60 * 60 * 24 * 365 // 1 year for dev
          : 60 * 60 * 24 * 7, // 7 days for prod
      },
    });
    
  2. Test social auth locally with ngrok:

    ngrok http 3000
    # Use ngrok URL as redirect URI in OAuth provider
    

Bundled Resources

This skill includes the following reference implementations:

  1. scripts/setup-d1-drizzle.sh - Complete D1 + Drizzle setup automation
  2. references/cloudflare-worker-drizzle.ts - Complete Worker with Drizzle auth
  3. references/cloudflare-worker-kysely.ts - Complete Worker with Kysely auth
  4. references/database-schema.ts - Complete better-auth Drizzle schema
  5. references/react-client-hooks.tsx - React components with auth hooks
  6. assets/auth-flow-diagram.md - Visual flow diagrams

Use Read tool to access these files when needed.


Token Efficiency

Without this skill: ~20,000 tokens (setup trial-and-error, debugging D1 adapter, schema generation, CORS, OAuth) With this skill: ~6,000 tokens (direct implementation from correct patterns) Savings: ~70% (14,000 tokens)

Errors prevented: 12 common issues documented with solutions


Additional Resources


Production Examples

Verified working repositories (all use Drizzle or Kysely):

  1. zwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle
  2. matthewlynch/better-auth-react-router-cloudflare-d1 - Kysely
  3. foxlau/react-router-v7-better-auth - Drizzle
  4. zpg6/better-auth-cloudflare - Drizzle (includes CLI)

None use a direct d1Adapter - all require Drizzle/Kysely.


Version Compatibility

Tested with:

  • better-auth@1.3.34
  • drizzle-orm@0.44.7
  • drizzle-kit@0.31.6
  • kysely@0.28.8
  • kysely-d1@0.4.0
  • @cloudflare/workers-types@latest
  • hono@4.0.0
  • Node.js 18+, Bun 1.0+

Breaking changes: Check changelog when upgrading: https://github.com/better-auth/better-auth/releases


Last verified: 2025-11-08 | Skill version: 2.0.0 | Breaking change: Corrected D1 adapter patterns