
Better Auth
- 67 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
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
- 67 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,189 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill better-authAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 67 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with security tasks during AI-assisted development.
Files
better-auth
Package: better-auth@1.4.15 (ESM-only since v1.4.0) Docs: <https://better-auth.com/docs> | GitHub: <https://github.com/better-auth/better-auth>
Environment Setup
| Variable | Purpose |
|---|---|
BETTER_AUTH_SECRET | Encryption secret (min 32 chars). Generate: openssl rand -base64 32 |
BETTER_AUTH_URL | Base URL (e.g., https://example.com) |
Only define baseURL/secret in config if env vars are NOT set. CLI looks for auth.ts in: ./, ./lib, ./utils, or ./src.
Core Config Options
| Option | Notes |
|---|---|
appName | Optional display name |
baseURL | Only if BETTER_AUTH_URL not set |
basePath | Default /api/auth. Set / for root. |
secret | Only if BETTER_AUTH_SECRET not set |
database | Required unless using stateless mode (v1.4+) |
secondaryStorage | Redis/KV for sessions and rate limits |
emailAndPassword | { enabled: true } to activate |
socialProviders | { google: { clientId, clientSecret }, ... } |
plugins | Array of plugins |
trustedOrigins | CSRF whitelist |
Plugin Reference
| Plugin | Description |
|---|---|
| twoFactor | TOTP, email OTP, backup codes |
| organization | Multi-tenant orgs, teams, invitations, RBAC |
| admin | User management, impersonation, banning |
| passkey | WebAuthn passwordless login |
| magicLink | Email-based passwordless login |
| jwt | JWT tokens with key rotation, JWKS |
| oauthProvider | Build your own OAuth 2.1 provider (separate @better-auth/oauth-provider package) |
| sso | Enterprise SSO with OIDC, OAuth2, SAML 2.0 (separate @better-auth/sso package) |
| scim | Enterprise user provisioning (separate @better-auth/scim package) |
| stripe | Payment and subscription management |
| bearer | API token auth for mobile/CLI |
| apiKey | Token-based auth with rate limits |
| oneTap | Google One Tap frictionless sign-in |
| anonymous | Guest user access without PII |
| genericOAuth | Custom OAuth providers with PKCE |
| emailOTP | Email-based one-time password auth |
| phoneNumber | Phone/SMS-based OTP sign-in |
| username | Username-based sign-in (alternative to email) |
| multiSession | Multiple accounts in same browser |
| openAPI | Interactive API docs at /api/auth/reference |
Session Strategies
| Strategy | Format | Use Case |
|---|---|---|
| Compact (default) | Base64url + HMAC-SHA256 | Smallest, fastest |
| JWT | Standard JWT | Interoperable |
| JWE | A256CBC-HS512 encrypted | Most secure |
Getting Started
For new projects or first-time Better Auth setup, use the official interactive setup skill:
npx skills add better-auth/skills -s create-auth-skillThis walks through framework detection, database selection, auth method choices, plugin setup, and generates the initial configuration.
Anti-Patterns
| Anti-Pattern | Correct Approach |
|---|---|
Using d1Adapter | Use Drizzle or Kysely adapter with provider: "sqlite" |
| Using table name in config | Use ORM model name, not DB table name |
| Forgetting CLI after plugin changes | Re-run npx @better-auth/cli@latest generate |
tanstackStartCookies() not last plugin | Must be the last plugin in array (TanStack Start) |
Checking session for login state | Check session?.user — session object is always truthy |
Missing nodejs_compat flag | Required in wrangler.toml for Cloudflare Workers |
| Kysely CamelCasePlugin with auth | Use separate Kysely instance without the plugin |
Using old reactStartCookies import | Renamed to tanstackStartCookies from better-auth/tanstack-start in v1.4.14 |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Setting baseURL and secret in config when env vars are already set | Only define these in config if BETTER_AUTH_URL and BETTER_AUTH_SECRET env vars are NOT set |
| Using CommonJS require syntax with better-auth v1.4+ | better-auth is ESM-only since v1.4.0; use import syntax exclusively |
| Not re-running CLI generate after adding or changing plugins | Always run npx @better-auth/cli@latest generate after plugin changes to update DB schema |
Checking session object truthy state for login detection | Check session?.user instead; the session object itself is always truthy |
Using d1Adapter directly for Cloudflare D1 | Use Drizzle or Kysely adapter with provider: "sqlite" for D1 compatibility |
Breaking Changes
| Version | Change |
|---|---|
| v1.4.14 | reactStartCookies renamed to tanstackStartCookies (import from better-auth/tanstack-start) |
| v1.4.6 | allowImpersonatingAdmins defaults to false |
| v1.4.0 | ESM-only (no CommonJS); SSO, SCIM, OAuth Provider moved to separate packages |
| v1.3.0 | Multi-team table structure: new teamMembers table needed |
Delegation
When working on auth, delegate to:
application-security— Security architecture and threat modelingdatabase— Drizzle ORM schema and migrationstanstack-start— TanStack Start integration patterns
Resources
- Docs: <https://better-auth.com/docs>
- Options Reference: <https://better-auth.com/docs/reference/options>
- LLMs.txt: <https://better-auth.com/llms.txt>
- Changelog: <https://www.better-auth.com/changelogs>
- TanStack Start: <https://www.better-auth.com/docs/integrations/tanstack>
- Expo: <https://www.better-auth.com/docs/integrations/expo>
References
- Database Adapters — Drizzle, Kysely, Prisma adapters, Cloudflare Workers factory pattern
- Session Management — Cookie cache, stateless sessions, storage priority, freshAge constraints
- Plugins and Social Auth — Plugin setup, OAuth 2.1 provider, admin RBAC, social provider scopes
- Email and Password — Verification, password reset, timing attack prevention, hashing (scrypt, argon2), token security
- Two-Factor Authentication — TOTP, email/SMS OTP, backup codes, trusted devices, 2FA session flow
- Organizations — Multi-tenant orgs, teams, invitations, RBAC, dynamic access control, lifecycle hooks
- Configuration — User/account config, rate limiting, hooks, CSRF, trusted origins, cookie/OAuth security, production checklist
- Framework Integration — TanStack Start setup, Expo/React Native, client imports, type safety
- Migration Guides — Migrate from NextAuth/Auth.js, Clerk, or Supabase Auth with schema mappings and session strategies
- Troubleshooting — D1 consistency, CORS, OAuth redirect, admin 403, nanostore refresh, known bugs
Configuration
User and Account Config
export const auth = betterAuth({
user: {
modelName: 'user',
additionalFields: {
/* custom fields */
},
changeEmail: { enabled: true },
deleteUser: { enabled: true },
},
account: {
accountLinking: { enabled: true },
},
});Required for registration: email and name fields.
Rate Limiting
Enabled by default in production, disabled in development. Per-endpoint stricter defaults on /sign-in, /sign-up, /change-password, /change-email: 3 req/10s.
Storage options: "memory" | "database" | "secondary-storage". Avoid memory on serverless — use secondary-storage.
export const auth = betterAuth({
rateLimit: {
window: 60,
max: 100,
customRules: {
'/sign-in/email': { window: 10, max: 3 },
'/two-factor/*': { window: 10, max: 3 },
'/forget-password': { window: 60, max: 5 },
},
storage: 'secondary-storage',
},
secondaryStorage: {
get: async (key) => env.KV.get(key),
set: async (key, value, ttl) =>
env.KV.put(key, value, { expirationTtl: ttl }),
delete: async (key) => env.KV.delete(key),
},
});Server-side calls via auth.api.* bypass rate limiting.
Database Hooks
export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async (user, ctx) => {
if (user.email?.endsWith('@blocked.com')) {
throw new APIError('BAD_REQUEST', {
message: 'Email domain not allowed',
});
}
return { data: { ...user, role: 'member' } };
},
after: async (user, ctx) => {
await sendWelcomeEmail(user.email);
},
},
},
},
});Available hooks: create, update for user, session, account, verification tables. Return false from a before hook to prevent the operation.
Security Auditing via Database Hooks
databaseHooks: {
session: {
create: {
after: async ({ data, ctx }) => {
/* log new session with IP, user agent */
},
},
delete: {
before: async ({ data }) => {
/* log session revocation */
},
},
},
user: {
update: {
after: async ({ data, oldData }) => {
/* log email/role changes */
},
},
delete: {
before: async ({ data }) => {
/* block protected users: return false */
},
},
},
}Endpoint Hooks
hooks: {
before: [{ matcher: (ctx) => ctx.path === '/sign-in/email', handler: createAuthMiddleware(async (ctx) => { /* ... */ }) }],
after: [{ matcher: (ctx) => true, handler: createAuthMiddleware(async (ctx) => { /* access ctx.context.returned */ }) }],
}Hook context (ctx.context): session, secret, authCookies, password.hash()/verify(), adapter, internalAdapter, generateId(), tables, baseURL.
Security Options
CSRF Protection
Three layers enabled by default — do not disable:
1. Origin/Referer header validation against trusted origins (when cookies present) 2. Fetch Metadata headers (Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest) 3. First-login protection via Fetch Metadata even without cookies
Trusted Origins
export const auth = betterAuth({
trustedOrigins: ['https://app.example.com', 'https://admin.example.com'],
});Via env: BETTER_AUTH_TRUSTED_ORIGINS=https://app.example.com,https://admin.example.com
Wildcard patterns: "*.example.com", "https://*.example.com", "exp://192.168.*.*:*/*"
Dynamic:
trustedOrigins: async (request) => {
const tenant = getTenantFromRequest(request);
return [`https://${tenant}.myapp.com`];
};Validated parameters: callbackURL, redirectTo, errorCallbackURL, newUserCallbackURL, origin.
Cookie Security
Defaults: secure: true on HTTPS/production, sameSite: "lax", httpOnly: true, path: "/", __Secure- prefix.
advanced: {
useSecureCookies: true,
cookiePrefix: 'myapp',
defaultCookieAttributes: { sameSite: 'strict', path: '/auth' },
cookies: {
session_token: {
name: 'auth-session',
attributes: { sameSite: 'strict' },
},
},
crossSubDomainCookies: {
enabled: true,
domain: '.example.com',
additionalCookies: ['session_token', 'session_data'],
},
}OAuth Security
- PKCE: Automatic, uses 128-char random
code_verifierwith S256 challenge - State parameter: 32-char random, expires after 10 minutes, contains callback URLs and PKCE verifier (encrypted). Strategy:
"cookie"(default) |"database" - Encrypt tokens:
account: { encryptOAuthTokens: true }(AES-256-GCM) — recommended if storing tokens for API access - Skip state cookie:
account: { skipStateCookieCheck: true }— only for mobile apps that cannot maintain cookies
IP-Based Security
advanced: {
ipAddress: {
ipAddressHeaders: ['x-forwarded-for', 'x-real-ip'],
disableIpTracking: false,
},
}Account Enumeration Prevention
Built-in on password reset:
1. Consistent response regardless of whether email exists 2. Dummy token generation + DB lookup when user not found 3. Background email sending (no response timing leak)
Return generic errors like "Invalid credentials" rather than "User not found" / "Incorrect password".
Secret Management
Better Auth looks for secrets in order: options.secret > BETTER_AUTH_SECRET env > AUTH_SECRET env. Rejects placeholder secrets in production. Warns if < 32 chars or entropy < 120 bits.
Production Security Checklist
- Strong unique secret (32+ chars, high entropy)
baseURLuses HTTPS- All valid origins in
trustedOrigins(frontend, mobile) - Rate limiting enabled with appropriate limits
- CSRF protection NOT disabled
- Secure cookies (automatic with HTTPS)
encryptOAuthTokens: trueif storing OAuth tokens- Background tasks configured for serverless
- Audit logging via
databaseHooksorhooks - IP tracking headers configured if behind a proxy
Database Adapters
Direct connections: Pass pg.Pool, mysql2 pool, better-sqlite3, or bun:sqlite instance.
ORM adapters: Import from better-auth/adapters/drizzle, better-auth/adapters/prisma, better-auth/adapters/mongodb.
Critical: Better Auth uses adapter model names, NOT underlying table names. If Prisma model is User mapping to table users, use modelName: "user" (Prisma reference), not "users".
Drizzle Adapter (Recommended for D1)
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { drizzle } from 'drizzle-orm/d1';
const db = drizzle(env.DB, { schema });
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
});Kysely Adapter (Alternative for D1)
import { Kysely } from 'kysely';
import { D1Dialect } from 'kysely-d1';
export const auth = betterAuth({
database: {
db: new Kysely({
dialect: new D1Dialect({ database: env.DB }),
}),
type: 'sqlite',
},
});Cloudflare Workers: Factory Pattern Required
D1 database bindings are only available inside the request handler. Use a factory function:
// WRONG - DB binding not available outside request
const db = drizzle(env.DB, { schema });
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
});
// CORRECT - Create auth instance per-request
export default {
fetch(request, env, ctx) {
const db = drizzle(env.DB, { schema });
const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
});
return auth.handler(request);
},
};Email and Password
Email Verification
Configuration
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
emailVerification: {
sendVerificationEmail: async ({ user, url, token }, request) => {
await sendEmail({
to: user.email,
subject: 'Verify your email address',
text: `Click the link to verify your email: ${url}`,
});
},
sendOnSignUp: true,
autoSignInAfterVerification: true,
expiresIn: 3600,
},
});requireEmailVerification only applies to email/password sign-ins. Unverified users receive a new verification email on each sign-in attempt.
The url parameter contains the full verification link. The token is available for building custom verification URLs.
Callback URLs
Always use absolute URLs (including the origin) for callback URLs:
const { data, error } = await authClient.signUp.email({
email,
password,
name,
callbackURL: 'https://example.com/callback',
});Password Reset
Configuration
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url, token }, request) => {
await sendEmail({
to: user.email,
subject: 'Reset your password',
text: `Click the link to reset your password: ${url}`,
});
},
resetPasswordTokenExpiresIn: 60 * 60, // 1 hour (default)
revokeSessionsOnPasswordReset: true,
onPasswordReset: async ({ user }, request) => {
console.log(`Password for ${user.email} has been reset`);
},
},
});Sending Reset Requests
Server-side:
const data = await auth.api.requestPasswordReset({
body: {
email: 'user@example.com',
redirectTo: 'https://example.com/reset-password',
},
});Client-side:
const { data, error } = await authClient.requestPasswordReset({
email: 'user@example.com',
redirectTo: 'https://example.com/reset-password',
});Token Security
- Cryptographically random tokens via
generateId(24)(24-character alphanumeric) - Expire after 1 hour by default (configure with
resetPasswordTokenExpiresInin seconds) - Single-use: tokens deleted immediately after successful reset
redirectTovalidated againsttrustedOrigins— malicious URLs rejected with 403
Timing Attack Prevention
Better Auth prevents user enumeration on password reset:
1. Background email sending via runInBackgroundOrAwait prevents response-time enumeration 2. Dummy token generation + database lookup when user not found 3. Constant response: "If this email exists in our system, check your email for the reset link"
On serverless platforms, configure background tasks explicitly:
export const auth = betterAuth({
advanced: {
backgroundTasks: {
handler: (promise) => {
waitUntil(promise);
},
},
},
});Password Requirements
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
minPasswordLength: 12, // Default: 8
maxPasswordLength: 256, // Default: 128
},
});Password Hashing
Better Auth uses scrypt by default:
- Slow and memory-intensive (resistance to brute-force)
- Natively supported in Node.js (no external dependencies)
- OWASP-recommended when Argon2id is not available
Custom Hashing (Argon2id)
import { betterAuth } from 'better-auth';
import { hash, verify, type Options } from '@node-rs/argon2';
const argon2Options: Options = {
memoryCost: 65536, // 64 MiB
timeCost: 3,
parallelism: 4,
outputLen: 32,
algorithm: 2, // Argon2id
};
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
password: {
hash: (password) => hash(password, argon2Options),
verify: ({ password, hash: storedHash }) =>
verify(storedHash, password, argon2Options),
},
},
});If you switch algorithms on an existing system, users with old-algorithm passwords cannot sign in. Plan a migration strategy (e.g., re-hash on next successful login).
Session Revocation on Reset
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
revokeSessionsOnPasswordReset: true,
},
});When enabled, all active sessions are invalidated after a successful password reset, forcing re-authentication on all devices.
Framework Integration
TanStack Start
CRITICAL: TanStack Start requires the tanstackStartCookies plugin for cookie handling (renamed from reactStartCookies in v1.4.14).
import { betterAuth } from 'better-auth';
import { tanstackStartCookies } from 'better-auth/tanstack-start';
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: 'sqlite' }),
plugins: [
twoFactor(),
organization(),
tanstackStartCookies(), // MUST be LAST plugin
],
});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),
},
},
});Session Nullability: useSession() always returns an object. Check session?.user, not just session:
const { data: session } = authClient.useSession();
if (session?.user) {
/* logged in */
}Expo/React Native
import { expoClient } from '@better-auth/expo';
import * as SecureStore from 'expo-secure-store';
const authClient = createAuthClient({
baseURL: 'https://api.example.com',
plugins: [expoClient({ storage: SecureStore })],
});
// OAuth with deep linking
await authClient.signIn.social({
provider: 'google',
callbackURL: 'myapp://auth/callback',
});
// Server trustedOrigins (development)
trustedOrigins: ['exp://**', 'myapp://'];Client Imports
// Framework-specific
import { createAuthClient } from 'better-auth/react'; // or /vue, /svelte, /solid
import { createAuthClient } from 'better-auth/client'; // vanilla
// Key methods
authClient.signUp.email({ email, password, name });
authClient.signIn.email({ email, password });
authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' });
authClient.signOut();
authClient.useSession(); // React hook
authClient.getSession(); // ImperativeType Safety
// Infer types from auth instance
type Session = typeof auth.$Infer.Session;
type User = typeof auth.$Infer.Session.user;
// Separate client/server projects
const client = createAuthClient<typeof auth>();Server-Side API (auth.api.*)
Every HTTP endpoint has a corresponding server-side method. Use for middleware, background jobs, admin operations.
// Session
const session = await auth.api.getSession({ headers: request.headers });
// User management
await auth.api.signUpEmail({
body: { email, password, name },
headers: request.headers,
});
// Organization
const org = await auth.api.createOrganization({
body: { name: 'Acme', slug: 'acme' },
headers: request.headers,
});
// Admin (requires user.role === 'admin' in DB)
const users = await auth.api.listUsers({
query: { search: 'john', limit: 10, offset: 0 },
headers: request.headers,
});80+ auto-generated endpoints at /api/auth/*. Use the OpenAPI plugin for interactive docs.
Migrating from Auth.js (NextAuth)
Schema Differences
Better Auth and Auth.js share a similar schema structure but differ in field names and types:
User table:
| Auth.js Field | Better Auth Field | Notes |
|---|---|---|
name (optional) | name (required) | Must backfill nulls before migration |
email (optional) | email (required) | Must backfill nulls before migration |
emailVerified | emailVerified | Changes from timestamp to boolean |
image | image | Same |
| N/A | createdAt | New required field |
| N/A | updatedAt | New required field |
Session table:
| Auth.js Field | Better Auth Field | Notes |
|---|---|---|
sessionToken | token | Renamed |
expires | expiresAt | Renamed |
userId | userId | Same |
| N/A | ipAddress | New optional field |
| N/A | userAgent | New optional field |
| N/A | createdAt | New required field |
| N/A | updatedAt | New required field |
Account table:
| Auth.js Field | Better Auth Field | Notes |
|---|---|---|
provider | providerId | Renamed |
providerAccountId | accountId | Renamed |
refresh_token | refreshToken | camelCase |
access_token | accessToken | camelCase |
expires_at (number) | accessTokenExpiresAt | Renamed, changes to Date |
| N/A | password | New field for credential accounts |
Database Migration Script
import { sql } from 'drizzle-orm';
async function migrateFromAuthJs(db: ReturnType<typeof drizzle>) {
await db.transaction(async (tx) => {
await tx.run(sql`
ALTER TABLE user ADD COLUMN "createdAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
`);
await tx.run(sql`
ALTER TABLE user ADD COLUMN "updatedAt" TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
`);
await tx.run(sql`
UPDATE user SET name = email WHERE name IS NULL;
`);
await tx.run(sql`
UPDATE user SET "emailVerified" = CASE
WHEN "emailVerified" IS NOT NULL THEN 1
ELSE 0
END;
`);
await tx.run(sql`
ALTER TABLE session RENAME COLUMN "sessionToken" TO "token";
`);
await tx.run(sql`
ALTER TABLE session RENAME COLUMN "expires" TO "expiresAt";
`);
await tx.run(sql`
ALTER TABLE account RENAME COLUMN "provider" TO "providerId";
`);
await tx.run(sql`
ALTER TABLE account RENAME COLUMN "providerAccountId" TO "accountId";
`);
});
}Server Configuration Change
// Before (Auth.js)
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GoogleProvider({ clientId: '...', clientSecret: '...' })],
});
// After (Better Auth)
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
socialProviders: {
google: { clientId: '...', clientSecret: '...' },
},
});Route Handler Change
// Before: app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;
// After: app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth);Client-Side Hooks
// Before (Auth.js)
import { useSession, signIn, signOut } from 'next-auth/react';
const { data: session, status } = useSession();
await signIn('github');
await signOut();
// After (Better Auth)
import { authClient } from '@/lib/auth-client';
const { data: session, isPending } = authClient.useSession();
await authClient.signIn.social({ provider: 'github' });
await authClient.signOut();Server-Side Session
// Before (Auth.js)
import { auth } from '@/lib/auth';
const session = await auth();
// After (Better Auth)
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
const session = await auth.api.getSession({ headers: await headers() });Migrating from Clerk
Key Differences
| Aspect | Clerk | Better Auth |
|---|---|---|
| Hosting | Third-party managed | Self-hosted |
| Cost | Monthly subscription | Free (open source) |
| User data | Stored on Clerk servers | Stored in your database |
| Customization | Limited by Clerk API | Full control via plugins |
Migration Steps
1. Export user data from Clerk Dashboard (CSV) or via Clerk API 2. Run Better Auth CLI to create database tables:
npx @better-auth/cli@latest migrate3. Import users with plugin-aware field mapping:
import { auth } from '@/lib/auth';
async function migrateFromClerk(clerkUsers: ClerkExportRow[]) {
const ctx = await auth.$context;
for (const user of clerkUsers) {
await ctx.adapter.create({
model: 'user',
data: {
id: user.id,
email: user.primary_email_address,
emailVerified: user.verified_email_addresses.length > 0,
name: `${user.first_name} ${user.last_name}`.trim(),
image: user.image_url,
createdAt: new Date(user.created_at),
updatedAt: new Date(user.updated_at),
},
forceAllowId: true,
});
if (user.password_digest) {
await ctx.adapter.create({
model: 'account',
data: {
userId: user.id,
providerId: 'credential',
accountId: user.id,
password: user.password_digest,
},
});
}
}
}4. Replace Clerk client SDK:
// Before (Clerk)
import { useUser, useAuth } from '@clerk/nextjs';
const { user } = useUser();
const { signOut } = useAuth();
// After (Better Auth)
import { authClient } from '@/lib/auth-client';
const { data: session } = authClient.useSession();
const user = session?.user;
await authClient.signOut();5. Replace middleware for route protection:
// Before (Clerk)
import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();
// After (Better Auth) - Use per-route checks instead of middleware
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect('/login');6. Reconfigure OAuth providers with the same OAuth app credentials
Migrating from Supabase Auth
Migration Script Pattern
Better Auth provides an official migration script pattern for Supabase:
import { generateId } from 'better-auth';
import { Pool } from 'pg';
import { auth } from '@/lib/auth';
const CONFIG = {
batchSize: 5000,
resumeFromId: null as string | null,
tempEmailDomain: 'temp.better-auth.com',
};
async function migrateFromSupabase() {
const pool = new Pool({ connectionString: process.env.SUPABASE_DB_URL });
const ctx = await auth.$context;
let lastId = CONFIG.resumeFromId;
let hasMore = true;
while (hasMore) {
const query = lastId
? `SELECT * FROM auth.users WHERE id > $1 ORDER BY id LIMIT $2`
: `SELECT * FROM auth.users ORDER BY id LIMIT $1`;
const params = lastId ? [lastId, CONFIG.batchSize] : [CONFIG.batchSize];
const { rows: users } = await pool.query(query, params);
if (users.length === 0) {
hasMore = false;
break;
}
for (const supaUser of users) {
const email =
supaUser.email ?? `${supaUser.phone}@${CONFIG.tempEmailDomain}`;
await ctx.adapter.create({
model: 'user',
data: {
id: supaUser.id,
email,
emailVerified: !!supaUser.email_confirmed_at,
name: supaUser.raw_user_meta_data?.full_name ?? email,
image: supaUser.raw_user_meta_data?.avatar_url,
createdAt: new Date(supaUser.created_at),
updatedAt: new Date(supaUser.updated_at ?? supaUser.created_at),
},
forceAllowId: true,
});
if (supaUser.encrypted_password) {
await ctx.adapter.create({
model: 'account',
data: {
id: generateId(),
userId: supaUser.id,
providerId: 'credential',
accountId: supaUser.id,
password: supaUser.encrypted_password,
},
});
}
}
lastId = users[users.length - 1].id;
}
await pool.end();
}Key Differences from Supabase Auth
| Aspect | Supabase Auth | Better Auth |
|---|---|---|
| Session storage | JWT in Supabase | Database or stateless |
| User metadata | raw_user_meta_data | additionalFields config |
| Phone-only users | Supported natively | Need phoneNumber plugin |
| Email confirmation | Timestamp-based | Boolean-based |
Database Migration Patterns
Using Better Auth CLI
The recommended approach for initial schema setup:
npx @better-auth/cli@latest generate
npx @better-auth/cli@latest migrateUsing Drizzle Kit (Recommended for D1/SQLite)
npx drizzle-kit generate
npx drizzle-kit migrateCustom Table Name Mapping
If your existing tables use different names, map them in config:
export const auth = betterAuth({
user: {
modelName: 'users',
fields: {
name: 'full_name',
email: 'email_address',
},
},
session: {
modelName: 'user_sessions',
fields: {
userId: 'user_id',
},
},
});Session Migration Strategies
Database Sessions (Auth.js/Clerk to Better Auth)
Existing sessions will be invalidated after migration. Users must re-authenticate. Plan for:
1. Schedule migration during low-traffic window 2. Migrate user and account data 3. Drop old session table (or rename) 4. Run @better-auth/cli migrate to create new session table 5. Users re-authenticate on next visit
JWT Sessions (Auth.js to Better Auth)
If migrating from Auth.js JWT strategy:
- Old JWTs become invalid immediately (different signing secret)
- No session table to migrate
- Users re-authenticate on next visit
- Consider a grace period with both auth systems running in parallel
Common Migration Pitfalls
| Pitfall | Solution |
|---|---|
Null name/email from Auth.js | Backfill required fields before migration |
emailVerified type mismatch | Convert timestamp to boolean (IS NOT NULL check) |
| Password hashes incompatible | Better Auth accepts bcrypt hashes from most providers |
| Phone-only Supabase users have no email | Assign temporary emails or enable phoneNumber plugin |
Clerk user IDs are prefixed (user_xxx) | Use forceAllowId: true to preserve original IDs |
D1 lacks ALTER TABLE DROP COLUMN | Use fresh migration pattern (drop and recreate tables) |
| Missing OAuth account records | Migrate both user and account tables; OAuth needs accountId |
| Old sessions still active after migration | Invalidate all sessions; users must re-authenticate |
Organizations
Setup
Server:
import { betterAuth } from 'better-auth';
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5,
membershipLimit: 100,
}),
],
});Run npx @better-auth/cli@latest migrate after adding the plugin.
Client:
import { createAuthClient } from 'better-auth/client';
import { organizationClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [organizationClient()],
});Creating Organizations
const { data, error } = await authClient.organization.create({
name: 'My Company',
slug: 'my-company',
logo: 'https://example.com/logo.png',
metadata: { plan: 'pro' },
});Creator is automatically assigned the owner role.
Dynamic Limits
organization({
allowUserToCreateOrganization: async (user) => {
return user.emailVerified === true;
},
organizationLimit: async (user) => {
return user.plan === 'premium' ? 20 : 3;
},
});Server-Side Creation (on behalf of a user)
await auth.api.createOrganization({
body: {
name: 'Client Organization',
slug: 'client-org',
userId: 'user-id-who-will-be-owner',
},
});userId cannot be used alongside session headers.
Active Organization
Many endpoints use the active organization automatically:
await authClient.organization.setActive({ organizationId });
await authClient.organization.listMembers();
await authClient.organization.listInvitations();
const { data } = await authClient.organization.getFullOrganization();
// data.organization, data.members, data.invitations, data.teamsMembers
Adding and Removing
await auth.api.addMember({
body: { userId: 'user-id', role: 'member', organizationId: 'org-id' },
});
await auth.api.addMember({
body: {
userId: 'user-id',
role: ['admin', 'moderator'],
organizationId: 'org-id',
},
});
await authClient.organization.removeMember({
memberIdOrEmail: 'user@example.com',
});The last owner cannot be removed. Transfer ownership first.
Updating Roles
await authClient.organization.updateMemberRole({
memberId: 'member-id',
role: 'admin',
});Dynamic Membership Limits
organization({
membershipLimit: async (user, organization) => {
if (organization.metadata?.plan === 'enterprise') return 1000;
return 50;
},
});Invitations
Email Setup
organization({
sendInvitationEmail: async (data) => {
const { email, organization, inviter, invitation } = data;
await sendEmail({
to: email,
subject: `Join ${organization.name}`,
html: `<p>${inviter.user.name} invited you.</p>
<a href="https://app.com/invite/${invitation.id}">Accept</a>`,
});
},
invitationExpiresIn: 60 * 60 * 24 * 7, // 7 days (default: 48 hours)
invitationLimit: 100,
cancelPendingInvitationsOnReInvite: true,
});Sending and Accepting
await authClient.organization.inviteMember({
email: 'newuser@example.com',
role: 'member',
});
await authClient.organization.acceptInvitation({
invitationId: 'invitation-id',
});Shareable Invitation URLs
const { data } = await authClient.organization.getInvitationURL({
email: 'newuser@example.com',
role: 'member',
callbackURL: 'https://app.com/dashboard',
});
// data.url — does NOT trigger sendInvitationEmailRoles and Permissions
Default roles:
| Role | Description |
|---|---|
owner | Full access, can delete organization |
admin | Manage members, invitations, settings |
member | Basic access |
Checking Permissions
const { data } = await authClient.organization.hasPermission({
permission: 'member:write',
});
// Client-side only (no API call, static check)
const canManage = authClient.organization.checkRolePermission({
role: 'admin',
permissions: ['member:write'],
});checkRolePermission does not work for dynamic access control — use hasPermission instead.
Dynamic Access Control
organization({
dynamicAccessControl: { enabled: true },
});Custom roles:
await authClient.organization.createRole({
role: 'moderator',
permission: { member: ['read'], invitation: ['read'] },
});
await authClient.organization.updateRole({
roleId: 'role-id',
permission: { member: ['read', 'write'] },
});
await authClient.organization.deleteRole({ roleId: 'role-id' });Pre-defined roles (owner, admin, member) cannot be deleted. Roles assigned to members cannot be deleted until members are reassigned.
Teams
Setup
organization({
teams: {
enabled: true,
maximumTeams: 20,
maximumMembersPerTeam: 50,
allowRemovingAllTeams: false,
},
});Managing Teams
const { data } = await authClient.organization.createTeam({
name: 'Engineering',
});
await authClient.organization.addTeamMember({
teamId: 'team-id',
userId: 'user-id',
});
await authClient.organization.removeTeamMember({
teamId: 'team-id',
userId: 'user-id',
});
await authClient.organization.setActiveTeam({ teamId: 'team-id' });Lifecycle Hooks
organization({
hooks: {
organization: {
beforeCreate: async ({ data, user }) => {
return {
data: {
...data,
metadata: { ...data.metadata, createdBy: user.id },
},
};
},
afterCreate: async ({ organization, member }) => {
await createDefaultResources(organization.id);
},
beforeDelete: async ({ organization }) => {
await archiveOrganizationData(organization.id);
},
},
member: {
afterCreate: async ({ member, organization }) => {
await notifyAdmins(organization.id, 'New member joined');
},
},
invitation: {
afterCreate: async ({ invitation, organization, inviter }) => {
await logInvitation(invitation);
},
},
},
});Schema Customization
organization({
schema: {
organization: {
modelName: 'workspace',
fields: { name: 'workspaceName' },
additionalFields: {
billingId: { type: 'string', required: false },
},
},
member: {
additionalFields: {
department: { type: 'string', required: false },
title: { type: 'string', required: false },
},
},
},
});Security Considerations
- Owner protection: Last owner cannot be removed or leave. Transfer ownership first.
- Invitation security: Expire after 48 hours by default. Only the invited email can accept. Admins can cancel pending invitations.
- Disable deletion:
disableOrganizationDeletion: trueprevents org deletion entirely. - Soft-delete via hooks: Throw from
beforeDeleteto archive instead of deleting.
Plugins and Social Auth
Import from dedicated paths for tree-shaking:
import { twoFactor } from 'better-auth/plugins/two-factor';
// NOT from "better-auth/plugins"Client plugins go in createAuthClient({ plugins: [...] }).
OAuth 2.1 Provider Plugin (v1.4.9+)
Build your own OAuth provider for MCP servers, third-party apps, or API access. Requires separate package @better-auth/oauth-provider:
import { oauthProvider } from '@better-auth/oauth-provider';
import { jwt } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
jwt(),
oauthProvider({
accessTokenExpiresIn: 3600,
refreshTokenExpiresIn: 2592000,
authorizationCodeExpiresIn: 600,
}),
],
});OAuth 2.1 compliant: PKCE mandatory, S256 only, no implicit flow. Supports authorization_code, refresh_token, client_credentials grant types.
Admin Plugin with RBAC
import { admin } from 'better-auth/plugins';
import { createAccessControl } from 'better-auth/plugins/access';
const ac = createAccessControl({
user: ['create', 'read', 'update', 'delete', 'ban', 'impersonate'],
project: ['create', 'read', 'update', 'delete', 'share'],
} as const);
admin({
ac,
roles: {
support: ac.newRole({ user: ['read', 'ban'], project: ['read'] }),
manager: ac.newRole({
user: ['read', 'update'],
project: ['create', 'read', 'update', 'delete'],
}),
},
allowImpersonatingAdmins: false, // Default since v1.4.6
});JWT Key Rotation (v1.4.0+)
import { jwt } from 'better-auth/plugins';
export const auth = betterAuth({
plugins: [
jwt({
keyRotation: {
enabled: true,
rotationInterval: 60 * 60 * 24 * 30,
keepPreviousKeys: 3,
},
algorithm: 'RS256',
exposeJWKS: true, // /api/auth/jwks
}),
],
});Social Provider Scopes
| Provider | Scope | Returns |
|---|---|---|
openid | User ID only | |
email | Email, email_verified | |
profile | Name, avatar, locale | |
| GitHub | user:email | Email (may be private) |
| GitHub | read:user | Name, avatar, profile URL, bio |
| Microsoft | User.Read | Full profile from Graph API |
| Discord | identify | Username, avatar |
| Discord | email | Email address |
| Apple | name | First/last (first auth only) |
| Apple | email | Email or relay address |
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 Management
Storage Priority
1. If secondaryStorage defined -> sessions go there (not DB) 2. Set session.storeSessionInDatabase: true to also persist to DB 3. No database + cookieCache -> fully stateless mode
Session Configuration
export const auth = betterAuth({
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Refresh every 24 hours
cookieCache: {
enabled: true,
maxAge: 300, // 5 minutes
encoding: 'compact', // or "jwt" or "jwe"
},
freshAge: 60 * 60 * 24, // 1 day - for sensitive operations
},
});Key constraint: updateAge must be <= freshAge, otherwise active sessions become "not fresh" before updatedAt is bumped.
Stateless Sessions (v1.4.0+)
export const auth = betterAuth({
session: {
cookieCache: {
enabled: true,
maxAge: 60 * 60 * 24 * 7,
encoding: 'jwt',
},
},
});Limitations:
- Cannot revoke sessions (user must wait for expiry)
- Cookie size limit ~4KB
- Server must have consistent
BETTER_AUTH_SECRETacross all instances
Troubleshooting
D1 Eventual Consistency (Session reads return null)
Session reads immediately after write return stale data. Use Cloudflare KV for session storage:
session: {
storage: {
get: async (sid) => { const s = await env.SESSIONS_KV.get(sid); return s ? JSON.parse(s) : null; },
set: async (sid, session, ttl) => { await env.SESSIONS_KV.put(sid, JSON.stringify(session), { expirationTtl: ttl }); },
delete: async (sid) => { await env.SESSIONS_KV.delete(sid); },
},
}CORS Errors for SPA Applications
Both CORS config and trustedOrigins must match frontend origin exactly (no trailing slash):
app.use(
'/api/auth/*',
cors({
origin: 'http://localhost:5173',
credentials: true,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
}),
);
export const auth = betterAuth({
trustedOrigins: ['http://localhost:5173'],
});CORS middleware must be registered BEFORE auth routes.
OAuth Redirect URI Mismatch
Callback URL must be exact character-for-character match: {baseURL}/api/auth/callback/{provider}.
Admin Plugin 403 Despite Custom Middleware
Admin plugin has dual authorization: your middleware AND user.role === 'admin' in database. Both must pass.
UPDATE user SET role = 'admin' WHERE email = 'admin@example.com';Organization updated_at Must Be Nullable
better-auth inserts null for updated_at on creation. Remove NOT NULL constraint:
updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()User Data Updates Not Reflecting in UI (TanStack Query)
better-auth uses nanostores, not TanStack Query. After updating user data:
authClient.$store.notify('$sessionSignal');
// Or use refetch:
const { data: session, refetch } = authClient.useSession();
await refetch();additionalFields string[] Returns Stringified JSON
When querying via Drizzle directly, string[] fields return '["a","b"]' (string) instead of arrays. Use auth.api (has transformer) or manually parse.
Expo fromJSONSchema Crash (Fixed)
Importing expoClient crashed with TypeError: Cannot read property 'fromJSONSchema' of undefined. This was fixed; upgrade to @better-auth/expo@1.4.10 or later. See issue #7491.
freshAge Based on Creation Time, Not Activity
freshAge checks time-since-creation, NOT recent activity. Set updateAge <= freshAge to avoid active sessions being rejected.
OAuth Token Endpoints Return Wrapped JSON
OAuth 2.1/OIDC token endpoints return { response: { ...tokens } } instead of spec-compliant top-level JSON. Tracked in issue #7355.
Schema Generation Fails for D1
Use Drizzle Kit instead of better-auth CLI for D1:
npx drizzle-kit generate
wrangler d1 migrations apply my-app-db --remoteTwo-Factor Authentication
Setup
Server:
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
appName: 'My App',
plugins: [
twoFactor({
issuer: 'My App',
}),
],
});Run npx @better-auth/cli@latest migrate after adding the plugin.
Client:
import { createAuthClient } from 'better-auth/client';
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = '/2fa';
},
}),
],
});Enabling 2FA for Users
const { data, error } = await authClient.twoFactor.enable({ password });
// data.totpURI — generate QR code from this
// data.backupCodes — show to user for safekeepingtwoFactorEnabled stays false until the user verifies their first TOTP code. To skip initial verification (not recommended):
twoFactor({ skipVerificationOnEnable: true });TOTP (Authenticator App)
QR Code Display
import QRCode from 'react-qr-code';
function TotpSetup({ totpURI }: { totpURI: string }) {
return <QRCode value={totpURI} />;
}Verifying Codes
Better Auth accepts codes from one period before and after current time (clock skew tolerance):
const { data, error } = await authClient.twoFactor.verifyTotp({
code,
trustDevice: true, // Remember this device for 30 days
});Configuration
twoFactor({
totpOptions: {
digits: 6, // 6 or 8 (default: 6)
period: 30, // Code validity in seconds (default: 30)
},
});OTP (Email/SMS)
Configuring Delivery
twoFactor({
otpOptions: {
sendOTP: async ({ user, otp }, ctx) => {
await sendEmail({
to: user.email,
subject: 'Your verification code',
text: `Your code is: ${otp}`,
});
},
period: 5, // Minutes (default: 3)
digits: 6,
allowedAttempts: 5, // Max attempts (default: 5)
},
});Sending and Verifying
await authClient.twoFactor.sendOtp();
const { data, error } = await authClient.twoFactor.verifyOtp({
code,
trustDevice: true,
});OTP Storage Security
twoFactor({
otpOptions: {
storeOTP: 'encrypted', // "plain" | "encrypted" | "hashed"
},
});Custom encryption:
twoFactor({
otpOptions: {
storeOTP: {
encrypt: async (token) => myEncrypt(token),
decrypt: async (token) => myDecrypt(token),
},
},
});Backup Codes
Generated automatically when 2FA is enabled. Always show to users at enable time.
Regenerating
const { data } = await authClient.twoFactor.generateBackupCodes({
password,
});
// data.backupCodes — invalidates all previous codesRecovery
const { data } = await authClient.twoFactor.verifyBackupCode({
code,
trustDevice: true,
});Each code is single-use — deleted after successful verification.
Configuration
twoFactor({
backupCodeOptions: {
amount: 10, // Default: 10
length: 10, // Default: 10
storeBackupCodes: 'encrypted', // "plain" | "encrypted"
},
});Sign-In Flow with 2FA
Client-Side Detection
const { data, error } = await authClient.signIn.email(
{ email, password },
{
onSuccess(context) {
if (context.data.twoFactorRedirect) {
window.location.href = '/2fa';
}
},
},
);Server-Side Detection
const response = await auth.api.signInEmail({
body: { email: 'user@example.com', password: 'password' },
});
if ('twoFactorRedirect' in response) {
// Handle 2FA verification
}Session Flow During 2FA
1. User signs in with credentials 2. Session cookie is removed (not yet authenticated) 3. Temporary two-factor cookie set (default: 10-minute expiration) 4. User verifies via TOTP, OTP, or backup code 5. Full session cookie created on success
twoFactor({ twoFactorCookieMaxAge: 600 }); // 10 minutes (default)Trusted Devices
await authClient.twoFactor.verifyTotp({
code: '123456',
trustDevice: true,
});
twoFactor({
trustDeviceMaxAge: 30 * 24 * 60 * 60, // 30 days (default)
});Trust period refreshes on each successful sign-in within the window.
Disabling 2FA
const { data } = await authClient.twoFactor.disable({ password });
// Trusted device records are revoked on disableSecurity Considerations
- Rate limiting: Built-in 3 requests per 10 seconds on all 2FA endpoints. OTP has additional attempt limiting via
allowedAttempts. - Encryption at rest: TOTP secrets encrypted with auth secret (symmetric). Backup codes encrypted by default. OTP storage is configurable.
- Constant-time comparison: OTP verification uses constant-time comparison to prevent timing attacks.
- Credential accounts only: 2FA only applies to credential-based accounts. Social auth accounts are assumed to handle 2FA through the provider.
Complete Configuration
import { betterAuth } from 'better-auth';
import { twoFactor } from 'better-auth/plugins';
export const auth = betterAuth({
appName: 'My App',
plugins: [
twoFactor({
issuer: 'My App',
totpOptions: { digits: 6, period: 30 },
otpOptions: {
sendOTP: async ({ user, otp }) => {
await sendEmail({
to: user.email,
subject: 'Your verification code',
text: `Your code is: ${otp}`,
});
},
period: 5,
allowedAttempts: 5,
storeOTP: 'encrypted',
},
backupCodeOptions: {
amount: 10,
length: 10,
storeBackupCodes: 'encrypted',
},
twoFactorCookieMaxAge: 600,
trustDeviceMaxAge: 30 * 24 * 60 * 60,
}),
],
});