Back to Skills

better-auth

jezweb
Updated Today
32 views
33
4
33
View on GitHub
Metawordaidesigndata

About

better-auth is a production-ready TypeScript authentication framework designed as a self-hosted alternative to Clerk or Auth.js, particularly for Cloudflare Workers projects. It supports comprehensive auth features including social providers, email/password, magic links, 2FA, passkeys, and RBAC. Critically, it requires Drizzle ORM or Kysely as database adapters for Cloudflare D1, as there is no direct D1 adapter.

Quick Install

Claude Code

Recommended
Plugin CommandRecommended
/plugin add https://github.com/jezweb/claude-skills
Git CloneAlternative
git clone https://github.com/jezweb/claude-skills.git ~/.claude/skills/better-auth

Copy and paste this command in Claude Code to install this skill

Documentation

better-auth Skill

Overview

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

⚠️ CRITICAL: D1 Adapter Requirements

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

  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: [email protected] (latest stable verified 2025-11-08)


Installation

Core Packages

Option 1: Drizzle ORM (Recommended)

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

Option 2: Kysely

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

Additional Dependencies

For Cloudflare Workers:

npm install @cloudflare/workers-types hono

For PostgreSQL (via Hyperdrive):

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

Social Providers (Optional):

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

Quick Start: Cloudflare Workers + D1 + Drizzle

Step 1: Create D1 Database

# Create database
wrangler d1 create my-app-db

# Copy the database_id from output

Add to wrangler.toml:

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

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

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

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

Step 2: Define Database Schema

File: src/db/schema.ts

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

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

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

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

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

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

Step 3: Configure Drizzle

File: drizzle.config.ts

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

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

Create .env file (for migrations):

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

Step 4: Generate and Apply Migrations

# Generate migration from schema
npx drizzle-kit generate

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

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

Step 5: Initialize Database and Auth

File: src/db/index.ts

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

export type Database = ReturnType<typeof createDatabase>;

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

File: src/auth.ts

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

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

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

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

    // Email/password authentication
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: true,
    },

    // Email verification configuration
    emailVerification: {
      sendVerificationEmail: async ({ user, url }) => {
        // TODO: Implement email sending
        // Use Resend, SendGrid, or Cloudflare Email Routing
        console.log(`Verification email for ${user.email}: ${url}`);
      },
      sendOnSignUp: true,
      autoSignInAfterVerification: true,
      expiresIn: 3600, // 1 hour
    },

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

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

Step 6: Create Worker with Auth Routes

File: src/index.ts

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

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

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

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

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

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

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

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

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

export default app;

Step 7: Set Secrets

# Generate a random secret
openssl rand -base64 32

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

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

Step 8: Deploy

# Test locally
npm run dev

# Deploy to Cloudflare
wrangler deploy

Alternative: Kysely Adapter Pattern

If you prefer Kysely over Drizzle:

File: src/auth.ts

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

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

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

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

    emailAndPassword: {
      enabled: true,
    },

    // ... other config
  });
}

Why CamelCasePlugin?

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


Client Integration (React)

File: src/lib/auth-client.ts

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

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

// For other frameworks:
// Vue: import { createAuthClient } from "better-auth/vue"
// Svelte: import { createAuthClient } from "better-auth/svelte"
// Vanilla: import { createAuthClient } from "better-auth/client"

File: src/components/LoginForm.tsx

"use client";

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

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

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

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

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

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

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

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

Use React Hook:

"use client";

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

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

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

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

Advanced Features

Two-Factor Authentication (2FA)

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

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

Client:

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

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

Organizations & Teams

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

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

Client:

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

// Invite member
await authClient.organization.inviteMember({
  organizationId: "org_123",
  email: "[email protected]",
  role: "member",
});

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

Rate Limiting with KV

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

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

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

Known Issues & Solutions

Issue 1: "d1Adapter is not exported" Error

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

Symptoms: TypeScript error or runtime error about missing export.

Solution: Use Drizzle or Kysely instead:

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

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

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

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


Issue 2: Schema Generation Fails

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

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

Solution: Use Drizzle Kit to generate migrations:

# Generate migration from Drizzle schema
npx drizzle-kit generate

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

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


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

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

Symptoms: Session reads fail, user data missing fields.

Solution: Use CamelCasePlugin with Kysely or configure Drizzle properly:

With Kysely:

import { CamelCasePlugin } from "kysely";

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

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


Issue 4: D1 Eventual Consistency

Problem: Session reads immediately after write return stale data.

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

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

import { betterAuth } from "better-auth";

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

Add to wrangler.toml:

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

Issue 5: CORS Errors for SPA Applications

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

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

Solution: Configure CORS headers in Worker:

import { cors } from "hono/cors";

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

Issue 6: OAuth Redirect URI Mismatch

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

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

Solution: Ensure exact match in OAuth provider settings:

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

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

Check better-auth callback URL:

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

Issue 7: Missing Dependencies

Problem: TypeScript errors or runtime errors about missing packages.

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

Solution: Install all required packages:

For Drizzle approach:

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

For Kysely approach:

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

Issue 8: Email Verification Not Sending

Problem: Email verification links never arrive.

Symptoms: User signs up, but no email received.

Solution: Implement sendVerificationEmail handler:

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

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


Issue 9: Session Expires Too Quickly

Problem: Session expires unexpectedly or never expires.

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

Solution: Configure session expiration:

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

Issue 10: Social Provider Missing User Data

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

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

Solution: Request additional scopes:

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

Issue 11: TypeScript Errors with Drizzle Schema

Problem: TypeScript complains about schema types.

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

Solution: Export proper types from database:

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

export type Database = DrizzleD1Database<typeof schema>;

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

Issue 12: Wrangler Dev Mode Not Working

Problem: wrangler dev fails with database errors.

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

Solution: Apply migrations locally first:

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

# Then run dev server
wrangler dev

Comparison: better-auth vs Alternatives

Featurebetter-authClerkAuth.js
HostingSelf-hostedThird-partySelf-hosted
CostFree (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 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:

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


Last verified: 2025-11-17 | Skill version: 2.0.1 | Changes: Email verification config structure, React imports, production repo list

GitHub Repository

jezweb/claude-skills
Path: skills/better-auth
aiautomationclaude-codeclaude-skillscloudflarereact

Related Skills

sglang

Meta

SGLang is a high-performance LLM serving framework that specializes in fast, structured generation for JSON, regex, and agentic workflows using its RadixAttention prefix caching. It delivers significantly faster inference, especially for tasks with repeated prefixes, making it ideal for complex, structured outputs and multi-turn conversations. Choose SGLang over alternatives like vLLM when you need constrained decoding or are building applications with extensive prefix sharing.

View skill

evaluating-llms-harness

Testing

This Claude Skill runs the lm-evaluation-harness to benchmark LLMs across 60+ standardized academic tasks like MMLU and GSM8K. It's designed for developers to compare model quality, track training progress, or report academic results. The tool supports various backends including HuggingFace and vLLM models.

View skill

llamaguard

Other

LlamaGuard is Meta's 7-8B parameter model for moderating LLM inputs and outputs across six safety categories like violence and hate speech. It offers 94-95% accuracy and can be deployed using vLLM, Hugging Face, or Amazon SageMaker. Use this skill to easily integrate content filtering and safety guardrails into your AI applications.

View skill

langchain

Meta

LangChain is a framework for building LLM applications using agents, chains, and RAG pipelines. It supports multiple LLM providers, offers 500+ integrations, and includes features like tool calling and memory management. Use it for rapid prototyping and deploying production systems like chatbots, autonomous agents, and question-answering services.

View skill