better-auth - D1 Adapter & Error Prevention Guide
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): INLINECODE1
- Kysely: INLINECODE2
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
backgroundTasks config - Deferred actions for better performance - Form data support - Email authentication with fetch metadata fallback
- Stripe enhancements - Flexible subscription lifecycle,
disableRedirect option
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/sso package) - Multi-team support ⚠️ Breaking:
teamId removed from member table, new teamMembers table 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: INLINECODE8
CODEBLOCK0
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:
CODEBLOCK1
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.
CODEBLOCK2
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:
CODEBLOCK3
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):
CODEBLOCK4
📚 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 | INLINECODE27 | 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:
CODEBLOCK5
Key Features:
- - OAuth 2.1 compliant - PKCE mandatory, S256 only, no implicit flow
- Three grant types:
authorization_code, refresh_token, INLINECODE35 - JWT or opaque tokens - Configurable token format
- Dynamic client registration - RFC 7591 compliant
- Consent management - Skip consent for trusted clients
- OIDC UserInfo endpoint -
/oauth2/userinfo with scope-based claims
Required Well-Known Endpoints:
CODEBLOCK6
Create OAuth Client:
CODEBLOCK7
📚 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):
CODEBLOCK8
Google One Tap Plugin
Frictionless single-tap sign-in for users already signed into Google:
CODEBLOCK9
Requirement: Configure authorized JavaScript origins in Google Cloud Console.
Anonymous Plugin
Guest access without requiring email/password:
CODEBLOCK10
Generic OAuth Plugin
Add custom OAuth providers not in the built-in list:
CODEBLOCK11
Callback URL pattern: {baseURL}/api/auth/oauth2/callback/{providerId}
Rate Limiting
Built-in rate limiting with customizable rules:
CODEBLOCK12
Note: Server-side calls via auth.api.* bypass rate limiting.
Stateless Sessions (v1.4.0+)
Store sessions entirely in signed cookies without database storage:
CODEBLOCK13
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_SECRET across all instances
JWT Key Rotation (v1.4.0+)
Automatically rotate JWT signing keys for enhanced security:
CODEBLOCK14
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/jwks for 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 |
|---|
| Google | INLINECODE43 | 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:
CODEBLOCK15
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 |
CODEBLOCK16
Fresh sessions: Some sensitive operations require recently created sessions. Configure freshAge to control this window.
New Social Providers (v1.4.9+)
CODEBLOCK17
Cloudflare Workers Requirements
⚠️ CRITICAL: Cloudflare Workers require AsyncLocalStorage support:
CODEBLOCK18
Without this flag, better-auth will fail with context-related errors.
Database Hooks
Execute custom logic during database operations:
CODEBLOCK19
Available hooks: create, update for user, session, account, verification tables.
Expo/React Native Integration
Complete mobile integration pattern:
CODEBLOCK20
app.json deep link setup:
CODEBLOCK21
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 |
|---|
| INLINECODE71 | POST | Register with email/password |
| INLINECODE72 |
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 |
|---|
| INLINECODE92 | POST | Initiate OAuth flow (provider specified in body) |
| INLINECODE93 |
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)
CODEBLOCK24
| Endpoint | Method | Description |
|---|
| INLINECODE96 | POST | Activate 2FA for user |
| INLINECODE97 |
POST | Deactivate 2FA |
|
/two-factor/get-totp-uri | GET | Get QR code URI for authenticator app |
|
/two-factor/verify-totp | POST | Validate TOTP code from authenticator |
|
/two-factor/send-otp | POST | Send OTP via email |
|
/two-factor/verify-otp | POST | Validate email OTP |
|
/two-factor/generate-backup-codes | POST | Create recovery codes |
|
/two-factor/verify-backup-code | POST | Use backup code for login |
|
/two-factor/view-backup-codes | GET | View current backup codes |
📚 Docs: https://www.better-auth.com/docs/plugins/2fa
Organization Plugin (Multi-Tenant SaaS)
CODEBLOCK25
Organizations (10 endpoints):
| Endpoint | Method | Description |
|---|
| INLINECODE105 | POST | Create organization |
| INLINECODE106 |
GET | List user's organizations |
|
/organization/get-full | GET | Get complete org details |
|
/organization/update | PUT | Modify organization |
|
/organization/delete | DELETE | Remove organization |
|
/organization/check-slug | GET | Verify slug availability |
|
/organization/set-active | POST | Set active organization context |
Members (8 endpoints):
| Endpoint | Method | Description |
|---|
| INLINECODE112 | GET | Get organization members |
| INLINECODE113 |
POST | Add member directly |
|
/organization/remove-member | DELETE | Remove member |
|
/organization/update-member-role | PUT | Change member role |
|
/organization/get-active-member | GET | Get current member info |
|
/organization/leave | POST | Leave organization |
Invitations (7 endpoints):
| Endpoint | Method | Description |
|---|
| INLINECODE118 | POST | Send invitation email |
| INLINECODE119 |
POST | Accept invite |
|
/organization/reject-invitation | POST | Reject invite |
|
/organization/cancel-invitation | POST | Cancel pending invite |
|
/organization/get-invitation | GET | Get invitation details |
|
/organization/list-invitations | GET | List org invitations |
|
/organization/list-user-invitations | GET | List user's pending invites |
Teams (8 endpoints):
| Endpoint | Method | Description |
|---|
| INLINECODE125 | POST | Create team within org |
| INLINECODE126 |
GET | List organization teams |
|
/organization/update-team | PUT | Modify team |
|
/organization/remove-team | DELETE | Remove team |
|
/organization/set-active-team | POST | Set active team context |
|
/organization/list-team-members | GET | List team members |
|
/organization/add-team-member | POST | Add member to team |
|
/organization/remove-team-member | DELETE | Remove team member |
Permissions & Roles (6 endpoints):
| Endpoint | Method | Description |
|---|
| INLINECODE133 | POST | Check if user has permission |
| INLINECODE134 |
POST | Create custom role |
|
/organization/delete-role | DELETE | Delete custom role |
|
/organization/list-roles | GET | List all roles |
|
/organization/get-role | GET | Get role details |
|
/organization/update-role | PUT | Modify role permissions |
📚 Docs: https://www.better-auth.com/docs/plugins/organization
Admin Plugin
CODEBLOCK26
| Endpoint | Method | Description |
|---|
| INLINECODE139 | POST | Create user as admin |
| INLINECODE140 |
GET | List all users (with filters/pagination) |
|
/admin/set-role | POST | Assign user role |
|
/admin/set-user-password | POST | Change user password |
|
/admin/update-user | PUT | Modify user details |
|
/admin/remove-user | DELETE | Delete user account |
|
/admin/ban-user | POST | Ban user account (with optional expiry) |
|
/admin/unban-user | POST | Unban user |
|
/admin/list-user-sessions | GET | Get user's active sessions |
|
/admin/revoke-user-session | DELETE | End specific user session |
|
/admin/revoke-user-sessions | DELETE | End all user sessions |
|
/admin/impersonate-user | POST | Start impersonating user |
|
/admin/stop-impersonating | POST | End impersonation session |
⚠️ Breaking Change (v1.4.6): allowImpersonatingAdmins now defaults to false. Set to true explicitly if you need admin-on-admin impersonation.
Custom Roles with Permissions (v1.4.10):
CODEBLOCK27
📚 Docs: https://www.better-auth.com/docs/plugins/admin
Other Plugin Endpoints
Passkey Plugin (5 endpoints) - Docs:
- -
/passkey/add, /sign-in/passkey, /passkey/list, /passkey/delete, INLINECODE159
Magic Link Plugin (2 endpoints) - Docs:
- -
/sign-in/magic-link, INLINECODE161
Username Plugin (2 endpoints) - Docs:
- -
/sign-in/username, INLINECODE163
Phone Number Plugin (5 endpoints) - Docs:
- -
/sign-in/phone-number, /phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, INLINECODE168
Email OTP Plugin (6 endpoints) - Docs:
- -
/email-otp/send-verification-otp, /email-otp/check-verification-otp, /sign-in/email-otp, /email-otp/verify-email, /forget-password/email-otp, INLINECODE174
Anonymous Plugin (1 endpoint) - Docs:
JWT Plugin (2 endpoints) - Docs:
- -
/token (get JWT), /jwks (public key for verification)
OpenAPI Plugin (2 endpoints) - Docs:
- -
/reference (interactive API docs with Scalar UI) - INLINECODE179 (get OpenAPI spec as JSON)
Server-Side API Methods (auth.api.*)
Every HTTP endpoint has a corresponding server-side method. Use these for:
- - Server-side middleware (protecting routes)
- Background jobs (user cleanup, notifications)
- Admin operations (bulk user management)
- Custom auth flows (programmatic session creation)
Core API Methods
CODEBLOCK28
Plugin API Methods
2FA Plugin:
CODEBLOCK29
Organization Plugin:
CODEBLOCK30
Admin Plugin:
// List users with pagination
const users = await auth.api.listUsers({
query: {
search: "john",
limit: 10,
offset: 0,
sortBy: "createdAt",
sortOrder: "desc",
},
headers: request.headers,
});
// Ban user
await auth.api.banUser({
body: {
userId: "user_123",
reason: "Violation of ToS",
expiresAt: new Date("2025-12-31"),
},
headers: request.headers,
});
// Impersonate user (for admin support)
const impersonationSession = await auth.api.impersonateUser({
body: {
userId: "user_123",
expiresIn: 3600, // 1 hour
},
headers: request.headers,
});
When to Use Which
| Use Case | Use HTTP Endpoints | Use auth.api.* Methods |
|---|
| Client-side auth | ✅ Yes | ❌ No |
| Server middleware |
❌ No | ✅ Yes |
|
Background jobs | ❌ No | ✅ Yes |
|
Admin dashboards | ✅ Yes (from client) | ✅ Yes (from server) |
|
Custom auth flows | ❌ No | ✅ Yes |
|
Mobile apps | ✅ Yes | ❌ No |
|
API routes | ✅ Yes (proxy to handler) | ✅ Yes (direct calls) |
Example: Protected Route Middleware
CODEBLOCK32
Discovering Available Endpoints
Use the OpenAPI plugin to see all endpoints in your configuration:
CODEBLOCK33
Interactive documentation: Visit INLINECODE182
This shows a Scalar UI with:
- - ✅ All available endpoints grouped by feature
- ✅ Request/response schemas with types
- ✅ Try-it-out functionality (test endpoints in browser)
- ✅ Authentication requirements
- ✅ Code examples in multiple languages
Programmatic access:
const schema = await auth.api.generateOpenAPISchema();
console.log(JSON.stringify(schema, null, 2));
// Returns full OpenAPI 3.0 spec
Quantified Time Savings
Building from scratch (manual implementation):
- - Core auth endpoints (sign-up, sign-in, OAuth, sessions): 40 hours
- Email verification & password reset: 10 hours
- 2FA system (TOTP, backup codes, email OTP): 20 hours
- Organizations (teams, invitations, RBAC): 60 hours
- Admin panel (user management, impersonation): 30 hours
- Testing & debugging: 50 hours
- Security hardening: 20 hours
Total manual effort: ~220 hours (5.5 weeks full-time)
With better-auth:
- - Initial setup: 2-4 hours
- Customization & styling: 2-4 hours
Total with better-auth: 4-8 hours
Savings: ~97% development time
Key Takeaway
better-auth provides 80+ production-ready endpoints covering:
- - ✅ Core authentication (20 endpoints)
- ✅ 2FA & passwordless (15 endpoints)
- ✅ Organizations & teams (35 endpoints)
- ✅ Admin & user management (15 endpoints)
- ✅ Social OAuth (auto-configured callbacks)
- ✅ OpenAPI documentation (interactive UI)
You write zero endpoint code. Just configure features and call auth.handler().
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:
CODEBLOCK35
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:
CODEBLOCK36
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.
⚠️ CRITICAL (v1.4.10+): Using Kysely's CamelCasePlugin breaks join parsing in better-auth adapter. The plugin converts join keys like _joined_user_user_id to _joinedUserUserId, causing user data to be null in session queries.
Solution for Drizzle: Define schema with camelCase from the start (as shown in examples).
Solution for Kysely with CamelCasePlugin: Use separate Kysely instance without CamelCasePlugin for better-auth:
CODEBLOCK37
Source: GitHub Issue #7136
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):
CODEBLOCK38
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 and ensure trustedOrigins match:
CODEBLOCK40
Common Mistakes:
- - Typo in origin URL (trailing slash, http vs https, wrong port)
- Mismatched origins between CORS config and INLINECODE195
- CORS middleware registered AFTER auth routes (must be before)
Source: GitHub Issue #7434
Issue 6: OAuth Redirect URI Mismatch
Problem: Social sign-in fails with "redirecturimismatch" error.
Symptoms: Google/GitHub OAuth returns error after user consent.
Solution: Ensure exact match in OAuth provider settings:
CODEBLOCK41
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:
CODEBLOCK43
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:
CODEBLOCK45
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:
CODEBLOCK46
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:
CODEBLOCK47
Issue 11: TypeScript Errors with Drizzle Schema
Problem: TypeScript complains about schema types.
Symptoms: INLINECODE199
Solution: Export proper types from database:
CODEBLOCK48
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:
CODEBLOCK49
Issue 13: User Data Updates Not Reflecting in UI (with TanStack Query)
Problem: After updating user data (e.g., avatar, name), changes don't appear in useSession() despite calling queryClient.invalidateQueries().
Symptoms: Avatar image or user profile data appears stale after successful update. TanStack Query cache shows updated data, but better-auth session still shows old values.
Root Cause: better-auth uses nanostores for session state management, not TanStack Query. Calling queryClient.invalidateQueries() only invalidates React Query cache, not the better-auth nanostore.
Solution: Manually notify the nanostore after updating user data:
CODEBLOCK50
When to use:
- - Using better-auth + TanStack Query together
- Updating user profile fields (name, image, email)
- Any operation that modifies session user data client-side
Alternative: Call refetch() from useSession(), but $store.notify() is more direct:
CODEBLOCK51
Note: $store is an undocumented internal API. This pattern is production-validated but may change in future better-auth versions.
Source: Community-discovered pattern, production use verified
Issue 14: apiKey Table Schema Mismatch with D1
Problem: better-auth CLI (npx @better-auth/cli generate) fails with "Failed to initialize database adapter" when using D1.
Symptoms: CLI cannot connect to D1 to introspect schema. Running migrations through CLI doesn't work.
Root Cause: The CLI expects a direct SQLite connection, but D1 requires Cloudflare's binding API.
Solution: Skip the CLI and create migrations manually using the documented apiKey schema:
CODEBLOCK52
Key Points:
- - The table has exactly 21 columns (as of better-auth v1.4+)
- Column names use
snake_case (e.g., rate_limit_time_window, not rateLimitTimeWindow) - D1 doesn't support
ALTER TABLE DROP COLUMN - if schema drifts, use fresh migration pattern (drop and recreate tables) - In Drizzle adapter config, use
apikey (lowercase) as the table name mapping
Fresh Migration Pattern for D1:
CODEBLOCK53
Source: Production debugging with D1 + better-auth apiKey plugin
Issue 15: Admin Plugin Requires DB Role (Dual-Auth)
Problem: Admin plugin methods like listUsers fail with "You are not allowed to list users" even though your middleware passes.
Symptoms: Custom requireAdmin middleware (checking ADMIN_EMAILS env var) passes, but auth.api.listUsers() returns 403.
Root Cause: better-auth admin plugin has two authorization layers:
- 1. Your middleware - Custom check (e.g., ADMIN_EMAILS)
- better-auth internal - Checks
user.role === 'admin' in database
Both must pass for admin plugin methods to work.
Solution: Set user role to 'admin' in the database:
CODEBLOCK54
Or use the admin UI/API to set roles after initial setup.
Why: The admin plugin's listUsers, banUser, impersonateUser, etc. all check user.role in the database, not your custom middleware logic.
Source: Production debugging - misleading error message led to root cause discovery via wrangler tail
Issue 16: Organization/Team updated_at Must Be Nullable
Problem: Organization creation fails with SQL constraint error even though API returns "slug already exists".
Symptoms:
- - Error message says "An organization with this slug already exists"
- Database table is actually empty
- INLINECODE223 shows: INLINECODE224
Root Cause: better-auth inserts null for updated_at on creation (only sets it on updates). If your schema has NOT NULL constraint, insert fails.
Solution: Make updated_at nullable in both schema and migrations:
CODEBLOCK55
CODEBLOCK56
Applies to: organization and team tables (possibly other plugin tables)
Source: Production debugging - wrangler tail revealed actual SQL error behind misleading "slug exists" message
Issue 17: API Response Double-Nesting (listMembers, etc.)
Problem: Custom API endpoints return double-nested data like { members: { members: [...], total: N } }.
Symptoms: UI shows "undefined" for counts, empty lists despite data existing.
Root Cause: better-auth methods like listMembers return { members: [...], total: N }. Wrapping with c.json({ members: result }) creates double nesting.
Solution: Extract the array from better-auth response:
CODEBLOCK57
Affected methods (return objects, not arrays):
- -
listMembers → INLINECODE237 - INLINECODE238 → INLINECODE239
- INLINECODE240 →
{ organizations: [...] } (check structure) - INLINECODE242 → INLINECODE243
Pattern: Always check better-auth method return types before wrapping in your API response.
Source: Production debugging - UI showed "undefined" count, API inspection revealed nesting issue
Issue 18: Expo Client fromJSONSchema Crash (v1.4.16)
Problem: Importing expoClient from @better-auth/expo/client crashes with TypeError: Cannot read property 'fromJSONSchema' of undefined on v1.4.16.
Symptoms: Runtime crash immediately when importing expoClient in React Native/Expo apps.
Root Cause: Regression introduced after PR #6933 (cookie-based OAuth state fix for Expo). One of 3 commits after f4a9f15 broke the build.
Solution:
- - Temporary: Use continuous build at commit
f4a9f15 (pre-regression) - Permanent: Wait for fix (issue #7491 open as of 2026-01-20)
CODEBLOCK58
Source: GitHub Issue #7491
Issue 19: additionalFields string[] Returns Stringified JSON
Problem: After v1.4.12, additionalFields with type: 'string[]' return stringified arrays ('["a","b"]') instead of native arrays when querying via Drizzle directly.
Symptoms: user.notificationTokens is a string, not an array. Code expecting arrays breaks.
Root Cause: In Drizzle adapter, string[] fields are stored with mode: 'json', which expects arrays. But better-auth v1.4.4+ passes strings to Drizzle, causing double-stringification. When querying directly via Drizzle, the value is a string, but when using better-auth internalAdapter, a transformer correctly returns an array.
Solution:
- 1. Use better-auth
internalAdapter instead of querying Drizzle directly (has transformer) - Change Drizzle schema to
.jsonb() for string[] fields - Manually parse JSON strings until fixed
CODEBLOCK59
Source: GitHub Issue #7440
Issue 20: additionalFields "returned" Property Blocks Input
Problem: Setting returned: false on additionalFields prevents field from being saved via API, even with input: true.
Symptoms: Field never saved to database when creating/updating via API endpoints.
Root Cause: The returned: false property blocks both read AND write operations, not just reads as intended. The input: true property should control write access independently.
Solution:
- - Don't use
returned: false if you need API write access - Write via server-side methods (
auth.api.*) instead
CODEBLOCK60
Source: GitHub Issue #7489
Issue 21: freshAge Based on Creation Time, Not Activity
Problem: session.freshAge checks time-since-creation, NOT recent activity. Active sessions become "not fresh" after freshAge elapses, even if used constantly.
Symptoms: "Fresh session required" endpoints reject valid active sessions.
Why It Happens: The freshSessionMiddleware checks Date.now() - (session.updatedAt || session.createdAt), but updatedAt only changes when the session is refreshed based on updateAge. If updateAge > freshAge, the session becomes "not fresh" before updatedAt is bumped.
Solution:
- 1. Set
updateAge <= freshAge to ensure freshness is updated before expiry - Avoid "fresh session required" gating for long-lived sessions
- Accept as design: freshAge is strictly time-since-creation (maintainer confirmed)
CODEBLOCK61
Source: GitHub Issue #7472
Issue 22: OAuth Token Endpoints Return Wrapped JSON
Problem: OAuth 2.1 and OIDC token endpoints return { "response": { ...tokens... } } instead of spec-compliant top-level JSON. OAuth clients expect { "access_token": "...", "token_type": "bearer" } at root.
Symptoms: OAuth clients fail with Bearer undefined or invalid_token.
Root Cause: The endpoint pipeline returns { response, headers, status } for internal use, which gets serialized directly for HTTP requests. This breaks OAuth/OIDC spec requirements.
Solution:
- - Temporary: Manually unwrap
.response field on client - Permanent: Wait for fix (issue #7355 open, accepting contributions)
CODEBLOCK62
Source: GitHub Issue #7355
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)
- 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
- 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
- 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();
Additional Resources
Official Documentation
- - Homepage: https://better-auth.com
- Introduction: https://www.better-auth.com/docs/introduction
- Installation: https://www.better-auth.com/docs/installation
- Basic Usage: https://www.better-auth.com/docs/basic-usage
Core Concepts
- - Session Management: https://www.better-auth.com/docs/concepts/session-management
- Users & Accounts: https://www.better-auth.com/docs/concepts/users-accounts
- Client SDK: https://www.better-auth.com/docs/concepts/client
- Plugins System: https://www.better-auth.com/docs/concepts/plugins
Authentication Methods
- - Email & Password: https://www.better-auth.com/docs/authentication/email-password
- OAuth Providers: https://www.better-auth.com/docs/concepts/oauth
Plugin Documentation
Core Plugins:
- - 2FA (Two-Factor): https://www.better-auth.com/docs/plugins/2fa
- Organization: https://www.better-auth.com/docs/plugins/organization
- Admin: https://www.better-auth.com/docs/plugins/admin
- Multi-Session: https://www.better-auth.com/docs/plugins/multi-session
- API Key: https://www.better-auth.com/docs/plugins/api-key
- Generic OAuth: https://www.better-auth.com/docs/plugins/generic-oauth
Passwordless Plugins:
- - Passkey: https://www.better-auth.com/docs/plugins/passkey
- Magic Link: https://www.better-auth.com/docs/plugins/magic-link
- Email OTP: https://www.better-auth.com/docs/plugins/email-otp
- Phone Number: https://www.better-auth.com/docs/plugins/phone-number
- Anonymous: https://www.better-auth.com/docs/plugins/anonymous
Advanced Plugins:
- - Username: https://www.better-auth.com/docs/plugins/username
- JWT: https://www.better-auth.com/docs/plugins/jwt
- OpenAPI: https://www.better-auth.com/docs/plugins/open-api
- OIDC Provider: https://www.better-auth.com/docs/plugins/oidc-provider
- SSO: https://www.better-auth.com/docs/plugins/sso
- Stripe: https://www.better-auth.com/docs/plugins/stripe
- MCP: https://www.better-auth.com/docs/plugins/mcp
Framework Integrations
- - TanStack Start: https://www.better-auth.com/docs/integrations/tanstack
- Expo (React Native): https://www.better-auth.com/docs/integrations/expo
Community & Support
- - GitHub: https://github.com/better-auth/better-auth (22.4k ⭐)
- Examples: https://github.com/better-auth/better-auth/tree/main/examples
- Discord: https://discord.gg/better-auth
- Changelog: https://github.com/better-auth/better-auth/releases
Related Documentation
- - Drizzle ORM: https://orm.drizzle.team/docs/get-started-sqlite
- Kysely: https://kysely.dev/
Production Examples
Verified working D1 repositories (all use Drizzle or Kysely):
- 1. zpg6/better-auth-cloudflare - Drizzle + D1 (includes CLI)
- zwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle + D1
- foxlau/react-router-v7-better-auth - Drizzle + D1
- matthewlynch/better-auth-react-router-cloudflare-d1 - Kysely + D1
None use a direct d1Adapter - all require Drizzle/Kysely.
Version Compatibility
Tested with:
- - INLINECODE280
- INLINECODE281
- INLINECODE282
- INLINECODE283
- INLINECODE284
- INLINECODE285
- INLINECODE286
- Node.js 18+, Bun 1.0+
Breaking changes:
- - v1.4.6:
allowImpersonatingAdmins defaults to INLINECODE288 - v1.4.0: ESM-only (no CommonJS)
- v1.3.0: Multi-team table structure change
Check changelog: https://github.com/better-auth/better-auth/releases
Community Resources
Cloudflare-specific guides: