better-auth - D1 Adapter & Error Prevention Guide
SkillCloud & infraSelf-hosted auth for TypeScript/Cloudflare Workers with social auth, 2FA, passkeys, organizations, RBAC, and 15+ plugins. Requires Drizzle ORM or Kysely for D1 (no direct adapter). Self-hosted alternative to Clerk/Auth.js.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the better-auth - D1 Adapter & Error Prevention Guide skill
What this skill tells your AI
The instructions your AI receives, as published by dennislee928/ethic-latex in .claude/skills/better-auth/SKILL.md and read by ahel’s review.
Package: better-auth@1.4.16 (Jan 21, 2026) Breaking Changes: ESM-only (v1.4.0), Admin impersonation prevention default (v1.4.6), Multi-team table changes (v1.3), D1 requires Drizzle/Kysely (no direct adapter)
⚠️ CRITICAL: D1 Adapter Requirement
better-auth DOES NOT have d1Adapter(). You MUST use:
- Drizzle ORM (recommended):
drizzleAdapter(db, { provider: "sqlite" }) - Kysely:
new Kysely({ dialect: new D1Dialect({ database: env.DB }) })
See Issue #1 below for details.
What's New in v1.4.10 (Dec 31, 2025)
Major Features:
- OAuth 2.1 Provider plugin - Build your own OAuth provider (replaces MCP plugin)
- Patreon OAuth provider - Social sign-in with Patreon
- Kick OAuth provider - With refresh token support
- Vercel OAuth provider - Sign in with Vercel
- Global
backgroundTasksconfig - Deferred actions for better performance - Form data support - Email authentication with fetch metadata fallback
- Stripe enhancements - Flexible subscription lifecycle,
disableRedirectoption
Admin Plugin Updates:
- ⚠️ Breaking: Impersonation of admins disabled by default (v1.4.6)
- Support role with permission-based user updates
- Role type inference improvements
Security Fixes:
- SAML XML parser hardening with configurable size constraints
- SAML assertion timestamp validation with per-provider clock skew
- SSO domain-verified provider trust
- Deprecated algorithm rejection
- Line nonce enforcement
📚 Docs: https://www.better-auth.com/changelogs
What's New in v1.4.0 (Nov 22, 2025)
Major Features:
- Stateless session management - Sessions without database storage
- ESM-only package ⚠️ Breaking: CommonJS no longer supported
- JWT key rotation - Automatic key rotation for enhanced security
- SCIM provisioning - Enterprise user provisioning protocol
- @standard-schema/spec - Replaces ZodType for validation
- CaptchaFox integration - Built-in CAPTCHA support
- Automatic server-side IP detection
- Cookie-based account data storage
- Multiple passkey origins support
- RP-Initiated Logout endpoint (OIDC)
📚 Docs: https://www.better-auth.com/changelogs
What's New in v1.3 (July 2025)
Major Features:
- SSO with SAML 2.0 - Enterprise single sign-on (moved to separate
@better-auth/ssopackage) - Multi-team support ⚠️ Breaking:
teamIdremoved from member table, newteamMemberstable required - Additional fields - Custom fields for organization/member/invitation models
- Performance improvements and bug fixes
📚 Docs: https://www.better-auth.com/blog/1-3
Alternative: Kysely Adapter Pattern
If you prefer Kysely over Drizzle:
File: src/auth.ts
import { betterAuth } from "better-auth";
import { Kysely, CamelCasePlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
type Env = {
DB: D1Database;
BETTER_AUTH_SECRET: string;
// ... other env vars
};
export function createAuth(env: Env) {
return betterAuth({
secret: env.BETTER_AUTH_SECRET,
// Kysely with D1Dialect
database: {
db: new Kysely({
dialect: new D1Dialect({
database: env.DB,
}),
plugins: [
// CRITICAL: Required if using Drizzle schema with snake_case
new CamelCasePlugin(),
],
}),
type: "sqlite",
},
emailAndPassword: {
enabled: true,
},
// ... other config
});
}
Why CamelCasePlugin?
If your Drizzle schema uses snake_case column names (e.g., email_verified), but better-auth expects camelCase (e.g., emailVerified), the CamelCasePlugin automatically converts between the two.
⚠️ Cloudflare Workers Note: D1 database bindings are only available inside the request handler (the fetch() function). You cannot initialize better-auth outside the request context. Use a factory function pattern:
// ❌ WRONG - DB binding not available outside request
const db = drizzle(env.DB, { schema }) // env.DB doesn't exist here
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)
}
}
Community Validation: Multiple production implementations confirm this pattern (Medium, AnswerOverflow, official Hono examples).
Framework Integrations
TanStack Start
⚠️ CRITICAL: TanStack Start requires the reactStartCookies plugin to handle cookie setting properly.
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { reactStartCookies } from "better-auth/react-start";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
plugins: [
twoFactor(),
organization(),
reactStartCookies(), // ⚠️ MUST be LAST plugin
],
});
Why it's needed: TanStack Start uses a special cookie handling system. Without this plugin, auth functions like signInEmail() and signUpEmail() won't set cookies properly, causing authentication to fail.
Important: The reactStartCookies plugin must be the last plugin in the array.
Session Nullability Pattern: When using useSession() in TanStack Start, the session object always exists, but session.user and session.session are null when not logged in:
const { data: session } = authClient.useSession()
// When NOT logged in:
console.log(session) // { user: null, session: null }
console.log(!!session) // true (unexpected!)
// Correct check:
if (session?.user) {
// User is logged in
}
Always check session?.user or session?.session, not just session. This is expected behavior (session object container always exists).
API Route Setup (/src/routes/api/auth/$.ts):
import { auth } from '@/lib/auth'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
})
📚 Official Docs: https://www.better-auth.com/docs/integrations/tanstack
Available Plugins (v1.4+)
Better Auth provides plugins for advanced authentication features:
| Plugin | Import | Description | Docs |
|---|---|---|---|
| OAuth 2.1 Provider | better-auth/plugins | Build OAuth 2.1 provider with PKCE, JWT tokens, consent flows (replaces MCP & OIDC plugins) | 📚 |
| SSO | better-auth/plugins | Enterprise Single Sign-On with OIDC, OAuth2, and SAML 2.0 support | 📚 |
| Stripe | better-auth/plugins | Payment and subscription management with flexible lifecycle handling | 📚 |
| MCP | better-auth/plugins | ⚠️ Deprecated - Use OAuth 2.1 Provider instead | 📚 |
| Expo | better-auth/expo | React Native/Expo with webBrowserOptions and last-login-method tracking | 📚 |
OAuth 2.1 Provider Plugin (New in v1.4.9)
Build your own OAuth provider for MCP servers, third-party apps, or API access:
import { betterAuth } from "better-auth";
import { oauthProvider } from "better-auth/plugins";
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
jwt(), // Required for token signing
oauthProvider({
// Token expiration (seconds)
accessTokenExpiresIn: 3600, // 1 hour
refreshTokenExpiresIn: 2592000, // 30 days
authorizationCodeExpiresIn: 600, // 10 minutes
}),
],
});
Key Features:
- OAuth 2.1 compliant - PKCE mandatory, S256 only, no implicit flow
- Three grant types:
authorization_code,refresh_token,client_credentials - JWT or opaque tokens - Configurable token format
- Dynamic client registration - RFC 7591 compliant
- Consent management - Skip consent for trusted clients
- OIDC UserInfo endpoint -
/oauth2/userinfowith scope-based claims
Required Well-Known Endpoints:
// app/api/.well-known/oauth-authorization-server/route.ts
export async function GET() {
return Response.json({
issuer: process.env.BETTER_AUTH_URL,
authorization_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/authorize`,
token_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/token`,
// ... other metadata
});
}
Create OAuth Client:
const client = await auth.api.createOAuthClient({
body: {
name: "My MCP Server",
redirectURLs: ["https://claude.ai/callback"],
type: "public", // or "confidential"
},
});
// Returns: { clientId, clientSecret (if confidential) }
📚 Full Docs: https://www.better-auth.com/docs/plugins/oauth-provider
⚠️ Note: This plugin is in active development and may not be suitable for production use yet.
Additional Plugins Reference
| Plugin | Description | Docs |
|---|---|---|
| Bearer | API token auth (alternative to cookies for APIs) | 📚 |
| One Tap | Google One Tap frictionless sign-in | 📚 |
| SCIM | Enterprise user provisioning (SCIM 2.0) | 📚 |
| Anonymous | Guest user access without PII | 📚 |
| Username | Username-based sign-in (alternative to email) | 📚 |
| Generic OAuth | Custom OAuth providers with PKCE | 📚 |
| Multi-Session | Multiple accounts in same browser | 📚 |
| API Key | Token-based auth with rate limits | 📚 |
Bearer Token Plugin
For API-only authentication (mobile apps, CLI tools, third-party integrations):
import { bearer } from "better-auth/plugins";
import { bearerClient } from "better-auth/client/plugins";
// Server
export const auth = betterAuth({
plugins: [bearer()],
});
// Client - Store token after sign-in
const { token } = await authClient.signIn.email({ email, password });
localStorage.setItem("auth_token", token);
// Client - Configure fetch to include token
const authClient = createAuthClient({
plugins: [bearerClient()],
fetchOptions: {
auth: { type: "Bearer", token: () => localStorage.getItem("auth_token") },
},
});
Google One Tap Plugin
Frictionless single-tap sign-in for users already signed into Google:
import { oneTap } from "better-auth/plugins";
import { oneTapClient } from "better-auth/client/plugins";
// Server
export const auth = betterAuth({
plugins: [oneTap()],
});
// Client
authClient.oneTap({
onSuccess: (session) => {
window.location.href = "/dashboard";
},
});
Requirement: Configure authorized JavaScript origins in Google Cloud Console.
Anonymous Plugin
Guest access without requiring email/password:
import { anonymous } from "better-auth/plugins";
// Server
export const auth = betterAuth({
plugins: [
anonymous({
emailDomainName: "anon.example.com", // temp@{id}.anon.example.com
onLinkAccount: async ({ anonymousUser, newUser }) => {
// Migrate anonymous user data to linked account
await migrateUserData(anonymousUser.id, newUser.id);
},
}),
],
});
// Client
await authClient.signIn.anonymous();
// Later: user can link to real account via signIn.social/email
Generic OAuth Plugin
Add custom OAuth providers not in the built-in list:
import { genericOAuth } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
{
providerId: "linear",
clientId: env.LINEAR_CLIENT_ID,
clientSecret: env.LINEAR_CLIENT_SECRET,
discoveryUrl: "https://linear.app/.well-known/openid-configuration",
scopes: ["openid", "email", "profile"],
pkce: true, // Recommended
},
],
}),
],
});
Callback URL pattern: {baseURL}/api/auth/oauth2/callback/{providerId}
Rate Limiting
Built-in rate limiting with customizable rules:
export const auth = betterAuth({
rateLimit: {
window: 60, // seconds (default: 60)
max: 100, // requests per window (default: 100)
// Custom rules for sensitive endpoints
customRules: {
"/sign-in/email": { window: 10, max: 3 },
"/two-factor/*": { window: 10, max: 3 },
"/forget-password": { window: 60, max: 5 },
},
// Use Redis/KV for distributed systems
storage: "secondary-storage", // or "database"
},
// Secondary storage for rate limiting
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),
},
});
Note: Server-side calls via auth.api.* bypass rate limiting.
Stateless Sessions (v1.4.0+)
Store sessions entirely in signed cookies without database storage:
export const auth = betterAuth({
session: {
// Stateless: No database storage, session lives in cookie only
storage: undefined, // or omit entirely
// Cookie configuration
cookieCache: {
enabled: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
encoding: "jwt", // Use JWT for stateless (not "compact")
},
// Session expiration
expiresIn: 60 * 60 * 24 * 7, // 7 days
},
});
When to Use:
| Storage Type | Use Case | Tradeoffs |
|---|---|---|
| Stateless (cookie-only) | Read-heavy apps, edge/serverless, no revocation needed | Can't revoke sessions, limited payload size |
| D1 Database | Full session management, audit trails, revocation | Eventual consistency issues |
| KV Storage | Strong consistency, high read performance | Extra binding setup |
Key Points:
- Stateless sessions can't be revoked (user must wait for expiry)
- Cookie size limit ~4KB (limits session data)
- Use
encoding: "jwt"for interoperability,"jwe"for encrypted - Server must have consistent
BETTER_AUTH_SECRETacross all instances
JWT Key Rotation (v1.4.0+)
Automatically rotate JWT signing keys for enhanced security:
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
jwt({
// Key rotation (optional, enterprise security)
keyRotation: {
enabled: true,
rotationInterval: 60 * 60 * 24 * 30, // Rotate every 30 days
keepPreviousKeys: 3, // Keep 3 old keys for validation
},
// Custom signing algorithm (default: HS256)
algorithm: "RS256", // Requires asymmetric keys
// JWKS endpoint (auto-generated at /api/auth/jwks)
exposeJWKS: true,
}),
],
});
Key Points:
- Key rotation prevents compromised key from having indefinite validity
- Old keys are kept temporarily to validate existing tokens
- JWKS endpoint at
/api/auth/jwksfor external services - Use RS256 for public key verification (microservices)
- HS256 (default) for single-service apps
Provider Scopes Reference
Common OAuth providers and the scopes needed for user data:
| Provider | Scope | Returns |
|---|---|---|
openid | User ID only | |
email | Email address, email_verified | |
profile | Name, avatar (picture), locale | |
| GitHub | user:email | Email address (may be private) |
read:user | Name, avatar, profile URL, bio | |
| Microsoft | openid | User ID only |
email | Email address | |
profile | Name, locale | |
User.Read | Full profile from Graph API | |
| Discord | identify | Username, avatar, discriminator |
email | Email address | |
| Apple | name | First/last name (first auth only) |
email | Email or relay address | |
| Patreon | identity | User ID, name |
identity[email] | Email address | |
| Vercel | (auto) | Email, name, avatar |
Configuration Example:
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"], // All user data
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["user:email", "read:user"], // Email + full profile
},
microsoft: {
clientId: env.MS_CLIENT_ID,
clientSecret: env.MS_CLIENT_SECRET,
scope: ["openid", "email", "profile", "User.Read"],
},
}
Session Cookie Caching
Three encoding strategies for session cookies:
| Strategy | Format | Use Case |
|---|---|---|
| Compact (default) | Base64url + HMAC-SHA256 | Smallest, fastest |
| JWT | Standard JWT | Interoperable |
| JWE | A256CBC-HS512 encrypted | Most secure |
export const auth = betterAuth({
session: {
cookieCache: {
enabled: true,
maxAge: 300, // 5 minutes
encoding: "compact", // or "jwt" or "jwe"
},
freshAge: 60 * 60 * 24, // 1 day - operations requiring fresh session
},
});
Fresh sessions: Some sensitive operations require recently created sessions. Configure freshAge to control this window.
New Social Providers (v1.4.9+)
socialProviders: {
// Patreon - Creator economy
patreon: {
clientId: env.PATREON_CLIENT_ID,
clientSecret: env.PATREON_CLIENT_SECRET,
scope: ["identity", "identity[email]"],
},
// Kick - Streaming platform (with refresh tokens)
kick: {
clientId: env.KICK_CLIENT_ID,
clientSecret: env.KICK_CLIENT_SECRET,
},
// Vercel - Developer platform
vercel: {
clientId: env.VERCEL_CLIENT_ID,
clientSecret: env.VERCEL_CLIENT_SECRET,
},
}
Cloudflare Workers Requirements
⚠️ CRITICAL: Cloudflare Workers require AsyncLocalStorage support:
# wrangler.toml
compatibility_flags = ["nodejs_compat"]
# or for older Workers:
# compatibility_flags = ["nodejs_als"]
Without this flag, better-auth will fail with context-related errors.
Database Hooks
Execute custom logic during database operations:
export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async (user, ctx) => {
// Validate or modify before creation
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) => {
// Send welcome email, create related records, etc.
await sendWelcomeEmail(user.email);
await createDefaultWorkspace(user.id);
},
},
},
session: {
create: {
after: async (session, ctx) => {
// Audit logging
await auditLog.create({ action: "session_created", userId: session.userId });
},
},
},
},
});
Available hooks: create, update for user, session, account, verification tables.
Expo/React Native Integration
Complete mobile integration pattern:
// Client setup with secure storage
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", // Deep link
});
// Or use ID token verification (no redirect)
await authClient.signIn.social({
provider: "google",
idToken: {
token: googleIdToken,
nonce: generatedNonce,
},
});
// Authenticated requests
const cookie = await authClient.getCookie();
await fetch("https://api.example.com/data", {
headers: { Cookie: cookie },
credentials: "omit",
});
app.json deep link setup:
{
"expo": {
"scheme": "myapp"
}
}
Server trustedOrigins (development):
trustedOrigins: ["exp://**", "myapp://"]
API Reference
Overview: What You Get For Free
When you call auth.handler(), better-auth automatically exposes 80+ production-ready REST endpoints at /api/auth/*. Every endpoint is also available as a server-side method via auth.api.* for programmatic use.
This dual-layer API system means:
- Clients (React, Vue, mobile apps) call HTTP endpoints directly
- Server-side code (middleware, background jobs) uses
auth.api.*methods - Zero boilerplate - no need to write auth endpoints manually
Time savings: Building this from scratch = ~220 hours. With better-auth = ~4-8 hours. 97% reduction.
Auto-Generated HTTP Endpoints
All endpoints are automatically exposed at /api/auth/* when using auth.handler().
Core Authentication Endpoints
| Endpoint | Method | Description |
|---|---|---|
/sign-up/email | POST | Register with email/password |
/sign-in/email | POST | Authenticate with email/password |
/sign-out | POST | Logout user |
/change-password | POST | Update password (requires current password) |
/forget-password | POST | Initiate password reset flow |
/reset-password | POST | Complete password reset with token |
/send-verification-email | POST | Send email verification link |
/verify-email | GET | Verify email with token (?token=<token>) |
/get-session | GET | Retrieve current session |
/list-sessions | GET | Get all active user sessions |
/revoke-session | POST | End specific session |
/revoke-other-sessions | POST | End all sessions except current |
/revoke-sessions | POST | End all user sessions |
/update-user | POST | Modify user profile (name, image) |
/change-email | POST | Update email address |
/set-password | POST | Add password to OAuth-only account |
/delete-user | POST | Remove user account |
/list-accounts | GET | Get linked authentication providers |
/link-social | POST | Connect OAuth provider to account |
/unlink-account | POST | Disconnect provider |
Social OAuth Endpoints
| Endpoint | Method | Description |
|---|---|---|
/sign-in/social | POST | Initiate OAuth flow (provider specified in body) |
/callback/:provider | GET | OAuth callback handler (e.g., /callback/google) |
/get-access-token | GET | Retrieve provider access token |
Example OAuth flow:
// Client initiates
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
// better-auth handles redirect to Google
// Google redirects back to /api/auth/callback/google
// better-auth creates session automatically
Plugin Endpoints
Two-Factor Authentication (2FA Plugin)
import { twoFactor } from "better-auth/plugins";
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 53
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
better-auth- Source
- github.com/dennislee928/ethic-latex