{
  "schemaVersion": "1.0",
  "item": {
    "slug": "better-auth",
    "name": "Better Auth",
    "source": "tencent",
    "type": "skill",
    "category": "AI 智能",
    "sourceUrl": "https://clawhub.ai/Veeramanikandanr48/better-auth",
    "canonicalUrl": "https://clawhub.ai/Veeramanikandanr48/better-auth",
    "targetPlatform": "OpenClaw"
  },
  "install": {
    "downloadMode": "redirect",
    "downloadUrl": "/downloads/better-auth",
    "sourceDownloadUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=better-auth",
    "sourcePlatform": "tencent",
    "targetPlatform": "OpenClaw",
    "installMethod": "Manual import",
    "extraction": "Extract archive",
    "prerequisites": [
      "OpenClaw"
    ],
    "packageFormat": "ZIP package",
    "includedAssets": [
      ".claude-plugin/plugin.json",
      "README.md",
      "SKILL.md",
      "assets/auth-flow-diagram.md",
      "commands/setup.md",
      "references/cloudflare-worker-drizzle.ts"
    ],
    "primaryDoc": "SKILL.md",
    "quickSetup": [
      "Download the package from Yavira.",
      "Extract the archive and review SKILL.md first.",
      "Import or place the package into your OpenClaw setup."
    ],
    "agentAssist": {
      "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
      "steps": [
        "Download the package from Yavira.",
        "Extract it into a folder your agent can access.",
        "Paste one of the prompts below and point your agent at the extracted folder."
      ],
      "prompts": [
        {
          "label": "New install",
          "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Then review README.md for any prerequisites, environment setup, or post-install checks. Tell me what you changed and call out any manual steps you could not complete."
        },
        {
          "label": "Upgrade existing",
          "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Then review README.md for any prerequisites, environment setup, or post-install checks. Summarize what changed and any follow-up checks I should run."
        }
      ]
    },
    "sourceHealth": {
      "source": "tencent",
      "status": "healthy",
      "reason": "direct_download_ok",
      "recommendedAction": "download",
      "checkedAt": "2026-04-23T16:43:11.935Z",
      "expiresAt": "2026-04-30T16:43:11.935Z",
      "httpStatus": 200,
      "finalUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=4claw-imageboard",
      "contentType": "application/zip",
      "probeMethod": "head",
      "details": {
        "probeUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=4claw-imageboard",
        "contentDisposition": "attachment; filename=\"4claw-imageboard-1.0.1.zip\"",
        "redirectLocation": null,
        "bodySnippet": null
      },
      "scope": "source",
      "summary": "Source download looks usable.",
      "detail": "Yavira can redirect you to the upstream package for this source.",
      "primaryActionLabel": "Download for OpenClaw",
      "primaryActionHref": "/downloads/better-auth"
    },
    "validation": {
      "installChecklist": [
        "Use the Yavira download entry.",
        "Review SKILL.md after the package is downloaded.",
        "Confirm the extracted package contains the expected setup assets."
      ],
      "postInstallChecks": [
        "Confirm the extracted package includes the expected docs or setup files.",
        "Validate the skill or prompts are available in your target agent workspace.",
        "Capture any manual follow-up steps the agent could not complete."
      ]
    },
    "downloadPageUrl": "https://openagent3.xyz/downloads/better-auth",
    "agentPageUrl": "https://openagent3.xyz/skills/better-auth/agent",
    "manifestUrl": "https://openagent3.xyz/skills/better-auth/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/better-auth/agent.md"
  },
  "agentAssist": {
    "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
    "steps": [
      "Download the package from Yavira.",
      "Extract it into a folder your agent can access.",
      "Paste one of the prompts below and point your agent at the extracted folder."
    ],
    "prompts": [
      {
        "label": "New install",
        "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Then review README.md for any prerequisites, environment setup, or post-install checks. Tell me what you changed and call out any manual steps you could not complete."
      },
      {
        "label": "Upgrade existing",
        "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Then review README.md for any prerequisites, environment setup, or post-install checks. Summarize what changed and any follow-up checks I should run."
      }
    ]
  },
  "documentation": {
    "source": "clawhub",
    "primaryDoc": "SKILL.md",
    "sections": [
      {
        "title": "better-auth - D1 Adapter & Error Prevention Guide",
        "body": "Package: better-auth@1.4.16 (Jan 21, 2026)\nBreaking 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)"
      },
      {
        "title": "⚠️ CRITICAL: D1 Adapter Requirement",
        "body": "better-auth DOES NOT have d1Adapter(). You MUST use:\n\nDrizzle ORM (recommended): drizzleAdapter(db, { provider: \"sqlite\" })\nKysely: new Kysely({ dialect: new D1Dialect({ database: env.DB }) })\n\nSee Issue #1 below for details."
      },
      {
        "title": "What's New in v1.4.10 (Dec 31, 2025)",
        "body": "Major Features:\n\nOAuth 2.1 Provider plugin - Build your own OAuth provider (replaces MCP plugin)\nPatreon OAuth provider - Social sign-in with Patreon\nKick OAuth provider - With refresh token support\nVercel OAuth provider - Sign in with Vercel\nGlobal backgroundTasks config - Deferred actions for better performance\nForm data support - Email authentication with fetch metadata fallback\nStripe enhancements - Flexible subscription lifecycle, disableRedirect option\n\nAdmin Plugin Updates:\n\n⚠️ Breaking: Impersonation of admins disabled by default (v1.4.6)\nSupport role with permission-based user updates\nRole type inference improvements\n\nSecurity Fixes:\n\nSAML XML parser hardening with configurable size constraints\nSAML assertion timestamp validation with per-provider clock skew\nSSO domain-verified provider trust\nDeprecated algorithm rejection\nLine nonce enforcement\n\n📚 Docs: https://www.better-auth.com/changelogs"
      },
      {
        "title": "What's New in v1.4.0 (Nov 22, 2025)",
        "body": "Major Features:\n\nStateless session management - Sessions without database storage\nESM-only package ⚠️ Breaking: CommonJS no longer supported\nJWT key rotation - Automatic key rotation for enhanced security\nSCIM provisioning - Enterprise user provisioning protocol\n@standard-schema/spec - Replaces ZodType for validation\nCaptchaFox integration - Built-in CAPTCHA support\nAutomatic server-side IP detection\nCookie-based account data storage\nMultiple passkey origins support\nRP-Initiated Logout endpoint (OIDC)\n\n📚 Docs: https://www.better-auth.com/changelogs"
      },
      {
        "title": "What's New in v1.3 (July 2025)",
        "body": "Major Features:\n\nSSO with SAML 2.0 - Enterprise single sign-on (moved to separate @better-auth/sso package)\nMulti-team support ⚠️ Breaking: teamId removed from member table, new teamMembers table required\nAdditional fields - Custom fields for organization/member/invitation models\nPerformance improvements and bug fixes\n\n📚 Docs: https://www.better-auth.com/blog/1-3"
      },
      {
        "title": "Alternative: Kysely Adapter Pattern",
        "body": "If you prefer Kysely over Drizzle:\n\nFile: src/auth.ts\n\nimport { betterAuth } from \"better-auth\";\nimport { Kysely, CamelCasePlugin } from \"kysely\";\nimport { D1Dialect } from \"kysely-d1\";\n\ntype Env = {\n  DB: D1Database;\n  BETTER_AUTH_SECRET: string;\n  // ... other env vars\n};\n\nexport function createAuth(env: Env) {\n  return betterAuth({\n    secret: env.BETTER_AUTH_SECRET,\n\n    // Kysely with D1Dialect\n    database: {\n      db: new Kysely({\n        dialect: new D1Dialect({\n          database: env.DB,\n        }),\n        plugins: [\n          // CRITICAL: Required if using Drizzle schema with snake_case\n          new CamelCasePlugin(),\n        ],\n      }),\n      type: \"sqlite\",\n    },\n\n    emailAndPassword: {\n      enabled: true,\n    },\n\n    // ... other config\n  });\n}\n\nWhy CamelCasePlugin?\n\nIf 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.\n\n⚠️ 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:\n\n// ❌ WRONG - DB binding not available outside request\nconst db = drizzle(env.DB, { schema }) // env.DB doesn't exist here\nexport const auth = betterAuth({ database: drizzleAdapter(db, { provider: \"sqlite\" }) })\n\n// ✅ CORRECT - Create auth instance per-request\nexport default {\n  fetch(request, env, ctx) {\n    const db = drizzle(env.DB, { schema })\n    const auth = betterAuth({ database: drizzleAdapter(db, { provider: \"sqlite\" }) })\n    return auth.handler(request)\n  }\n}\n\nCommunity Validation: Multiple production implementations confirm this pattern (Medium, AnswerOverflow, official Hono examples)."
      },
      {
        "title": "TanStack Start",
        "body": "⚠️ CRITICAL: TanStack Start requires the reactStartCookies plugin to handle cookie setting properly.\n\nimport { betterAuth } from \"better-auth\";\nimport { drizzleAdapter } from \"better-auth/adapters/drizzle\";\nimport { reactStartCookies } from \"better-auth/react-start\";\n\nexport const auth = betterAuth({\n  database: drizzleAdapter(db, { provider: \"sqlite\" }),\n  plugins: [\n    twoFactor(),\n    organization(),\n    reactStartCookies(), // ⚠️ MUST be LAST plugin\n  ],\n});\n\nWhy 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.\n\nImportant: The reactStartCookies plugin must be the last plugin in the array.\n\nSession 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:\n\nconst { data: session } = authClient.useSession()\n\n// When NOT logged in:\nconsole.log(session) // { user: null, session: null }\nconsole.log(!!session) // true (unexpected!)\n\n// Correct check:\nif (session?.user) {\n  // User is logged in\n}\n\nAlways check session?.user or session?.session, not just session. This is expected behavior (session object container always exists).\n\nAPI Route Setup (/src/routes/api/auth/$.ts):\n\nimport { auth } from '@/lib/auth'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/api/auth/$')({\n  server: {\n    handlers: {\n      GET: ({ request }) => auth.handler(request),\n      POST: ({ request }) => auth.handler(request),\n    },\n  },\n})\n\n📚 Official Docs: https://www.better-auth.com/docs/integrations/tanstack"
      },
      {
        "title": "Available Plugins (v1.4+)",
        "body": "Better Auth provides plugins for advanced authentication features:\n\nPluginImportDescriptionDocsOAuth 2.1 Providerbetter-auth/pluginsBuild OAuth 2.1 provider with PKCE, JWT tokens, consent flows (replaces MCP & OIDC plugins)📚SSObetter-auth/pluginsEnterprise Single Sign-On with OIDC, OAuth2, and SAML 2.0 support📚Stripebetter-auth/pluginsPayment and subscription management with flexible lifecycle handling📚MCPbetter-auth/plugins⚠️ Deprecated - Use OAuth 2.1 Provider instead📚Expobetter-auth/expoReact Native/Expo with webBrowserOptions and last-login-method tracking📚"
      },
      {
        "title": "OAuth 2.1 Provider Plugin (New in v1.4.9)",
        "body": "Build your own OAuth provider for MCP servers, third-party apps, or API access:\n\nimport { betterAuth } from \"better-auth\";\nimport { oauthProvider } from \"better-auth/plugins\";\nimport { jwt } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  plugins: [\n    jwt(), // Required for token signing\n    oauthProvider({\n      // Token expiration (seconds)\n      accessTokenExpiresIn: 3600,      // 1 hour\n      refreshTokenExpiresIn: 2592000,  // 30 days\n      authorizationCodeExpiresIn: 600, // 10 minutes\n    }),\n  ],\n});\n\nKey Features:\n\nOAuth 2.1 compliant - PKCE mandatory, S256 only, no implicit flow\nThree grant types: authorization_code, refresh_token, client_credentials\nJWT or opaque tokens - Configurable token format\nDynamic client registration - RFC 7591 compliant\nConsent management - Skip consent for trusted clients\nOIDC UserInfo endpoint - /oauth2/userinfo with scope-based claims\n\nRequired Well-Known Endpoints:\n\n// app/api/.well-known/oauth-authorization-server/route.ts\nexport async function GET() {\n  return Response.json({\n    issuer: process.env.BETTER_AUTH_URL,\n    authorization_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/authorize`,\n    token_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/token`,\n    // ... other metadata\n  });\n}\n\nCreate OAuth Client:\n\nconst client = await auth.api.createOAuthClient({\n  body: {\n    name: \"My MCP Server\",\n    redirectURLs: [\"https://claude.ai/callback\"],\n    type: \"public\", // or \"confidential\"\n  },\n});\n// Returns: { clientId, clientSecret (if confidential) }\n\n📚 Full Docs: https://www.better-auth.com/docs/plugins/oauth-provider\n\n⚠️ Note: This plugin is in active development and may not be suitable for production use yet."
      },
      {
        "title": "Additional Plugins Reference",
        "body": "PluginDescriptionDocsBearerAPI token auth (alternative to cookies for APIs)📚One TapGoogle One Tap frictionless sign-in📚SCIMEnterprise user provisioning (SCIM 2.0)📚AnonymousGuest user access without PII📚UsernameUsername-based sign-in (alternative to email)📚Generic OAuthCustom OAuth providers with PKCE📚Multi-SessionMultiple accounts in same browser📚API KeyToken-based auth with rate limits📚\n\nBearer Token Plugin\n\nFor API-only authentication (mobile apps, CLI tools, third-party integrations):\n\nimport { bearer } from \"better-auth/plugins\";\nimport { bearerClient } from \"better-auth/client/plugins\";\n\n// Server\nexport const auth = betterAuth({\n  plugins: [bearer()],\n});\n\n// Client - Store token after sign-in\nconst { token } = await authClient.signIn.email({ email, password });\nlocalStorage.setItem(\"auth_token\", token);\n\n// Client - Configure fetch to include token\nconst authClient = createAuthClient({\n  plugins: [bearerClient()],\n  fetchOptions: {\n    auth: { type: \"Bearer\", token: () => localStorage.getItem(\"auth_token\") },\n  },\n});\n\nGoogle One Tap Plugin\n\nFrictionless single-tap sign-in for users already signed into Google:\n\nimport { oneTap } from \"better-auth/plugins\";\nimport { oneTapClient } from \"better-auth/client/plugins\";\n\n// Server\nexport const auth = betterAuth({\n  plugins: [oneTap()],\n});\n\n// Client\nauthClient.oneTap({\n  onSuccess: (session) => {\n    window.location.href = \"/dashboard\";\n  },\n});\n\nRequirement: Configure authorized JavaScript origins in Google Cloud Console.\n\nAnonymous Plugin\n\nGuest access without requiring email/password:\n\nimport { anonymous } from \"better-auth/plugins\";\n\n// Server\nexport const auth = betterAuth({\n  plugins: [\n    anonymous({\n      emailDomainName: \"anon.example.com\", // temp@{id}.anon.example.com\n      onLinkAccount: async ({ anonymousUser, newUser }) => {\n        // Migrate anonymous user data to linked account\n        await migrateUserData(anonymousUser.id, newUser.id);\n      },\n    }),\n  ],\n});\n\n// Client\nawait authClient.signIn.anonymous();\n// Later: user can link to real account via signIn.social/email\n\nGeneric OAuth Plugin\n\nAdd custom OAuth providers not in the built-in list:\n\nimport { genericOAuth } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  plugins: [\n    genericOAuth({\n      config: [\n        {\n          providerId: \"linear\",\n          clientId: env.LINEAR_CLIENT_ID,\n          clientSecret: env.LINEAR_CLIENT_SECRET,\n          discoveryUrl: \"https://linear.app/.well-known/openid-configuration\",\n          scopes: [\"openid\", \"email\", \"profile\"],\n          pkce: true, // Recommended\n        },\n      ],\n    }),\n  ],\n});\n\nCallback URL pattern: {baseURL}/api/auth/oauth2/callback/{providerId}"
      },
      {
        "title": "Rate Limiting",
        "body": "Built-in rate limiting with customizable rules:\n\nexport const auth = betterAuth({\n  rateLimit: {\n    window: 60,  // seconds (default: 60)\n    max: 100,    // requests per window (default: 100)\n\n    // Custom rules for sensitive endpoints\n    customRules: {\n      \"/sign-in/email\": { window: 10, max: 3 },\n      \"/two-factor/*\": { window: 10, max: 3 },\n      \"/forget-password\": { window: 60, max: 5 },\n    },\n\n    // Use Redis/KV for distributed systems\n    storage: \"secondary-storage\", // or \"database\"\n  },\n\n  // Secondary storage for rate limiting\n  secondaryStorage: {\n    get: async (key) => env.KV.get(key),\n    set: async (key, value, ttl) => env.KV.put(key, value, { expirationTtl: ttl }),\n    delete: async (key) => env.KV.delete(key),\n  },\n});\n\nNote: Server-side calls via auth.api.* bypass rate limiting."
      },
      {
        "title": "Stateless Sessions (v1.4.0+)",
        "body": "Store sessions entirely in signed cookies without database storage:\n\nexport const auth = betterAuth({\n  session: {\n    // Stateless: No database storage, session lives in cookie only\n    storage: undefined, // or omit entirely\n\n    // Cookie configuration\n    cookieCache: {\n      enabled: true,\n      maxAge: 60 * 60 * 24 * 7, // 7 days\n      encoding: \"jwt\", // Use JWT for stateless (not \"compact\")\n    },\n\n    // Session expiration\n    expiresIn: 60 * 60 * 24 * 7, // 7 days\n  },\n});\n\nWhen to Use:\n\nStorage TypeUse CaseTradeoffsStateless (cookie-only)Read-heavy apps, edge/serverless, no revocation neededCan't revoke sessions, limited payload sizeD1 DatabaseFull session management, audit trails, revocationEventual consistency issuesKV StorageStrong consistency, high read performanceExtra binding setup\n\nKey Points:\n\nStateless sessions can't be revoked (user must wait for expiry)\nCookie size limit ~4KB (limits session data)\nUse encoding: \"jwt\" for interoperability, \"jwe\" for encrypted\nServer must have consistent BETTER_AUTH_SECRET across all instances"
      },
      {
        "title": "JWT Key Rotation (v1.4.0+)",
        "body": "Automatically rotate JWT signing keys for enhanced security:\n\nimport { jwt } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  plugins: [\n    jwt({\n      // Key rotation (optional, enterprise security)\n      keyRotation: {\n        enabled: true,\n        rotationInterval: 60 * 60 * 24 * 30, // Rotate every 30 days\n        keepPreviousKeys: 3, // Keep 3 old keys for validation\n      },\n\n      // Custom signing algorithm (default: HS256)\n      algorithm: \"RS256\", // Requires asymmetric keys\n\n      // JWKS endpoint (auto-generated at /api/auth/jwks)\n      exposeJWKS: true,\n    }),\n  ],\n});\n\nKey Points:\n\nKey rotation prevents compromised key from having indefinite validity\nOld keys are kept temporarily to validate existing tokens\nJWKS endpoint at /api/auth/jwks for external services\nUse RS256 for public key verification (microservices)\nHS256 (default) for single-service apps"
      },
      {
        "title": "Provider Scopes Reference",
        "body": "Common OAuth providers and the scopes needed for user data:\n\nProviderScopeReturnsGoogleopenidUser ID onlyemailEmail address, email_verifiedprofileName, avatar (picture), localeGitHubuser:emailEmail address (may be private)read:userName, avatar, profile URL, bioMicrosoftopenidUser ID onlyemailEmail addressprofileName, localeUser.ReadFull profile from Graph APIDiscordidentifyUsername, avatar, discriminatoremailEmail addressApplenameFirst/last name (first auth only)emailEmail or relay addressPatreonidentityUser ID, nameidentity[email]Email addressVercel(auto)Email, name, avatar\n\nConfiguration Example:\n\nsocialProviders: {\n  google: {\n    clientId: env.GOOGLE_CLIENT_ID,\n    clientSecret: env.GOOGLE_CLIENT_SECRET,\n    scope: [\"openid\", \"email\", \"profile\"], // All user data\n  },\n  github: {\n    clientId: env.GITHUB_CLIENT_ID,\n    clientSecret: env.GITHUB_CLIENT_SECRET,\n    scope: [\"user:email\", \"read:user\"], // Email + full profile\n  },\n  microsoft: {\n    clientId: env.MS_CLIENT_ID,\n    clientSecret: env.MS_CLIENT_SECRET,\n    scope: [\"openid\", \"email\", \"profile\", \"User.Read\"],\n  },\n}"
      },
      {
        "title": "Session Cookie Caching",
        "body": "Three encoding strategies for session cookies:\n\nStrategyFormatUse CaseCompact (default)Base64url + HMAC-SHA256Smallest, fastestJWTStandard JWTInteroperableJWEA256CBC-HS512 encryptedMost secure\n\nexport const auth = betterAuth({\n  session: {\n    cookieCache: {\n      enabled: true,\n      maxAge: 300, // 5 minutes\n      encoding: \"compact\", // or \"jwt\" or \"jwe\"\n    },\n    freshAge: 60 * 60 * 24, // 1 day - operations requiring fresh session\n  },\n});\n\nFresh sessions: Some sensitive operations require recently created sessions. Configure freshAge to control this window."
      },
      {
        "title": "New Social Providers (v1.4.9+)",
        "body": "socialProviders: {\n  // Patreon - Creator economy\n  patreon: {\n    clientId: env.PATREON_CLIENT_ID,\n    clientSecret: env.PATREON_CLIENT_SECRET,\n    scope: [\"identity\", \"identity[email]\"],\n  },\n\n  // Kick - Streaming platform (with refresh tokens)\n  kick: {\n    clientId: env.KICK_CLIENT_ID,\n    clientSecret: env.KICK_CLIENT_SECRET,\n  },\n\n  // Vercel - Developer platform\n  vercel: {\n    clientId: env.VERCEL_CLIENT_ID,\n    clientSecret: env.VERCEL_CLIENT_SECRET,\n  },\n}"
      },
      {
        "title": "Cloudflare Workers Requirements",
        "body": "⚠️ CRITICAL: Cloudflare Workers require AsyncLocalStorage support:\n\n# wrangler.toml\ncompatibility_flags = [\"nodejs_compat\"]\n# or for older Workers:\n# compatibility_flags = [\"nodejs_als\"]\n\nWithout this flag, better-auth will fail with context-related errors."
      },
      {
        "title": "Database Hooks",
        "body": "Execute custom logic during database operations:\n\nexport const auth = betterAuth({\n  databaseHooks: {\n    user: {\n      create: {\n        before: async (user, ctx) => {\n          // Validate or modify before creation\n          if (user.email?.endsWith(\"@blocked.com\")) {\n            throw new APIError(\"BAD_REQUEST\", { message: \"Email domain not allowed\" });\n          }\n          return { data: { ...user, role: \"member\" } };\n        },\n        after: async (user, ctx) => {\n          // Send welcome email, create related records, etc.\n          await sendWelcomeEmail(user.email);\n          await createDefaultWorkspace(user.id);\n        },\n      },\n    },\n    session: {\n      create: {\n        after: async (session, ctx) => {\n          // Audit logging\n          await auditLog.create({ action: \"session_created\", userId: session.userId });\n        },\n      },\n    },\n  },\n});\n\nAvailable hooks: create, update for user, session, account, verification tables."
      },
      {
        "title": "Expo/React Native Integration",
        "body": "Complete mobile integration pattern:\n\n// Client setup with secure storage\nimport { expoClient } from \"@better-auth/expo\";\nimport * as SecureStore from \"expo-secure-store\";\n\nconst authClient = createAuthClient({\n  baseURL: \"https://api.example.com\",\n  plugins: [expoClient({ storage: SecureStore })],\n});\n\n// OAuth with deep linking\nawait authClient.signIn.social({\n  provider: \"google\",\n  callbackURL: \"myapp://auth/callback\", // Deep link\n});\n\n// Or use ID token verification (no redirect)\nawait authClient.signIn.social({\n  provider: \"google\",\n  idToken: {\n    token: googleIdToken,\n    nonce: generatedNonce,\n  },\n});\n\n// Authenticated requests\nconst cookie = await authClient.getCookie();\nawait fetch(\"https://api.example.com/data\", {\n  headers: { Cookie: cookie },\n  credentials: \"omit\",\n});\n\napp.json deep link setup:\n\n{\n  \"expo\": {\n    \"scheme\": \"myapp\"\n  }\n}\n\nServer trustedOrigins (development):\n\ntrustedOrigins: [\"exp://**\", \"myapp://\"]"
      },
      {
        "title": "Overview: What You Get For Free",
        "body": "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.\n\nThis dual-layer API system means:\n\nClients (React, Vue, mobile apps) call HTTP endpoints directly\nServer-side code (middleware, background jobs) uses auth.api.* methods\nZero boilerplate - no need to write auth endpoints manually\n\nTime savings: Building this from scratch = ~220 hours. With better-auth = ~4-8 hours. 97% reduction."
      },
      {
        "title": "Auto-Generated HTTP Endpoints",
        "body": "All endpoints are automatically exposed at /api/auth/* when using auth.handler().\n\nCore Authentication Endpoints\n\nEndpointMethodDescription/sign-up/emailPOSTRegister with email/password/sign-in/emailPOSTAuthenticate with email/password/sign-outPOSTLogout user/change-passwordPOSTUpdate password (requires current password)/forget-passwordPOSTInitiate password reset flow/reset-passwordPOSTComplete password reset with token/send-verification-emailPOSTSend email verification link/verify-emailGETVerify email with token (?token=<token>)/get-sessionGETRetrieve current session/list-sessionsGETGet all active user sessions/revoke-sessionPOSTEnd specific session/revoke-other-sessionsPOSTEnd all sessions except current/revoke-sessionsPOSTEnd all user sessions/update-userPOSTModify user profile (name, image)/change-emailPOSTUpdate email address/set-passwordPOSTAdd password to OAuth-only account/delete-userPOSTRemove user account/list-accountsGETGet linked authentication providers/link-socialPOSTConnect OAuth provider to account/unlink-accountPOSTDisconnect provider\n\nSocial OAuth Endpoints\n\nEndpointMethodDescription/sign-in/socialPOSTInitiate OAuth flow (provider specified in body)/callback/:providerGETOAuth callback handler (e.g., /callback/google)/get-access-tokenGETRetrieve provider access token\n\nExample OAuth flow:\n\n// Client initiates\nawait authClient.signIn.social({\n  provider: \"google\",\n  callbackURL: \"/dashboard\",\n});\n\n// better-auth handles redirect to Google\n// Google redirects back to /api/auth/callback/google\n// better-auth creates session automatically\n\nPlugin Endpoints\n\nTwo-Factor Authentication (2FA Plugin)\n\nimport { twoFactor } from \"better-auth/plugins\";\n\nEndpointMethodDescription/two-factor/enablePOSTActivate 2FA for user/two-factor/disablePOSTDeactivate 2FA/two-factor/get-totp-uriGETGet QR code URI for authenticator app/two-factor/verify-totpPOSTValidate TOTP code from authenticator/two-factor/send-otpPOSTSend OTP via email/two-factor/verify-otpPOSTValidate email OTP/two-factor/generate-backup-codesPOSTCreate recovery codes/two-factor/verify-backup-codePOSTUse backup code for login/two-factor/view-backup-codesGETView current backup codes\n\n📚 Docs: https://www.better-auth.com/docs/plugins/2fa\n\nOrganization Plugin (Multi-Tenant SaaS)\n\nimport { organization } from \"better-auth/plugins\";\n\nOrganizations (10 endpoints):\n\nEndpointMethodDescription/organization/createPOSTCreate organization/organization/listGETList user's organizations/organization/get-fullGETGet complete org details/organization/updatePUTModify organization/organization/deleteDELETERemove organization/organization/check-slugGETVerify slug availability/organization/set-activePOSTSet active organization context\n\nMembers (8 endpoints):\n\nEndpointMethodDescription/organization/list-membersGETGet organization members/organization/add-memberPOSTAdd member directly/organization/remove-memberDELETERemove member/organization/update-member-rolePUTChange member role/organization/get-active-memberGETGet current member info/organization/leavePOSTLeave organization\n\nInvitations (7 endpoints):\n\nEndpointMethodDescription/organization/invite-memberPOSTSend invitation email/organization/accept-invitationPOSTAccept invite/organization/reject-invitationPOSTReject invite/organization/cancel-invitationPOSTCancel pending invite/organization/get-invitationGETGet invitation details/organization/list-invitationsGETList org invitations/organization/list-user-invitationsGETList user's pending invites\n\nTeams (8 endpoints):\n\nEndpointMethodDescription/organization/create-teamPOSTCreate team within org/organization/list-teamsGETList organization teams/organization/update-teamPUTModify team/organization/remove-teamDELETERemove team/organization/set-active-teamPOSTSet active team context/organization/list-team-membersGETList team members/organization/add-team-memberPOSTAdd member to team/organization/remove-team-memberDELETERemove team member\n\nPermissions & Roles (6 endpoints):\n\nEndpointMethodDescription/organization/has-permissionPOSTCheck if user has permission/organization/create-rolePOSTCreate custom role/organization/delete-roleDELETEDelete custom role/organization/list-rolesGETList all roles/organization/get-roleGETGet role details/organization/update-rolePUTModify role permissions\n\n📚 Docs: https://www.better-auth.com/docs/plugins/organization\n\nAdmin Plugin\n\nimport { admin } from \"better-auth/plugins\";\n\n// v1.4.10 configuration options\nadmin({\n  defaultRole: \"user\",\n  adminRoles: [\"admin\"],\n  adminUserIds: [\"user_abc123\"], // Always grant admin to specific users\n  impersonationSessionDuration: 3600, // 1 hour (seconds)\n  allowImpersonatingAdmins: false, // ⚠️ Default changed in v1.4.6\n  defaultBanReason: \"Violation of Terms of Service\",\n  bannedUserMessage: \"Your account has been suspended\",\n})\n\nEndpointMethodDescription/admin/create-userPOSTCreate user as admin/admin/list-usersGETList all users (with filters/pagination)/admin/set-rolePOSTAssign user role/admin/set-user-passwordPOSTChange user password/admin/update-userPUTModify user details/admin/remove-userDELETEDelete user account/admin/ban-userPOSTBan user account (with optional expiry)/admin/unban-userPOSTUnban user/admin/list-user-sessionsGETGet user's active sessions/admin/revoke-user-sessionDELETEEnd specific user session/admin/revoke-user-sessionsDELETEEnd all user sessions/admin/impersonate-userPOSTStart impersonating user/admin/stop-impersonatingPOSTEnd impersonation session\n\n⚠️ Breaking Change (v1.4.6): allowImpersonatingAdmins now defaults to false. Set to true explicitly if you need admin-on-admin impersonation.\n\nCustom Roles with Permissions (v1.4.10):\n\nimport { createAccessControl } from \"better-auth/plugins/access\";\n\n// Define resources and permissions\nconst ac = createAccessControl({\n  user: [\"create\", \"read\", \"update\", \"delete\", \"ban\", \"impersonate\"],\n  project: [\"create\", \"read\", \"update\", \"delete\", \"share\"],\n} as const);\n\n// Create custom roles\nconst supportRole = ac.newRole({\n  user: [\"read\", \"ban\"],      // Can view and ban users\n  project: [\"read\"],          // Can view projects\n});\n\nconst managerRole = ac.newRole({\n  user: [\"read\", \"update\"],\n  project: [\"create\", \"read\", \"update\", \"delete\"],\n});\n\n// Use in plugin\nadmin({\n  ac,\n  roles: {\n    support: supportRole,\n    manager: managerRole,\n  },\n})\n\n📚 Docs: https://www.better-auth.com/docs/plugins/admin\n\nOther Plugin Endpoints\n\nPasskey Plugin (5 endpoints) - Docs:\n\n/passkey/add, /sign-in/passkey, /passkey/list, /passkey/delete, /passkey/update\n\nMagic Link Plugin (2 endpoints) - Docs:\n\n/sign-in/magic-link, /magic-link/verify\n\nUsername Plugin (2 endpoints) - Docs:\n\n/sign-in/username, /username/is-available\n\nPhone Number Plugin (5 endpoints) - Docs:\n\n/sign-in/phone-number, /phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-password\n\nEmail OTP Plugin (6 endpoints) - Docs:\n\n/email-otp/send-verification-otp, /email-otp/check-verification-otp, /sign-in/email-otp, /email-otp/verify-email, /forget-password/email-otp, /email-otp/reset-password\n\nAnonymous Plugin (1 endpoint) - Docs:\n\n/sign-in/anonymous\n\nJWT Plugin (2 endpoints) - Docs:\n\n/token (get JWT), /jwks (public key for verification)\n\nOpenAPI Plugin (2 endpoints) - Docs:\n\n/reference (interactive API docs with Scalar UI)\n/generate-openapi-schema (get OpenAPI spec as JSON)"
      },
      {
        "title": "Server-Side API Methods (auth.api.*)",
        "body": "Every HTTP endpoint has a corresponding server-side method. Use these for:\n\nServer-side middleware (protecting routes)\nBackground jobs (user cleanup, notifications)\nAdmin operations (bulk user management)\nCustom auth flows (programmatic session creation)\n\nCore API Methods\n\n// Authentication\nawait auth.api.signUpEmail({\n  body: { email, password, name },\n  headers: request.headers,\n});\n\nawait auth.api.signInEmail({\n  body: { email, password, rememberMe: true },\n  headers: request.headers,\n});\n\nawait auth.api.signOut({ headers: request.headers });\n\n// Session Management\nconst session = await auth.api.getSession({ headers: request.headers });\n\nawait auth.api.listSessions({ headers: request.headers });\n\nawait auth.api.revokeSession({\n  body: { token: \"session_token_here\" },\n  headers: request.headers,\n});\n\n// User Management\nawait auth.api.updateUser({\n  body: { name: \"New Name\", image: \"https://...\" },\n  headers: request.headers,\n});\n\nawait auth.api.changeEmail({\n  body: { newEmail: \"newemail@example.com\" },\n  headers: request.headers,\n});\n\nawait auth.api.deleteUser({\n  body: { password: \"current_password\" },\n  headers: request.headers,\n});\n\n// Account Linking\nawait auth.api.linkSocialAccount({\n  body: { provider: \"google\" },\n  headers: request.headers,\n});\n\nawait auth.api.unlinkAccount({\n  body: { providerId: \"google\", accountId: \"google_123\" },\n  headers: request.headers,\n});\n\nPlugin API Methods\n\n2FA Plugin:\n\n// Enable 2FA\nconst { totpUri, backupCodes } = await auth.api.enableTwoFactor({\n  body: { issuer: \"MyApp\" },\n  headers: request.headers,\n});\n\n// Verify TOTP code\nawait auth.api.verifyTOTP({\n  body: { code: \"123456\", trustDevice: true },\n  headers: request.headers,\n});\n\n// Generate backup codes\nconst { backupCodes } = await auth.api.generateBackupCodes({\n  headers: request.headers,\n});\n\nOrganization Plugin:\n\n// Create organization\nconst org = await auth.api.createOrganization({\n  body: { name: \"Acme Corp\", slug: \"acme\" },\n  headers: request.headers,\n});\n\n// Add member\nawait auth.api.addMember({\n  body: {\n    userId: \"user_123\",\n    role: \"admin\",\n    organizationId: org.id,\n  },\n  headers: request.headers,\n});\n\n// Check permissions\nconst hasPermission = await auth.api.hasPermission({\n  body: {\n    organizationId: org.id,\n    permission: \"users:delete\",\n  },\n  headers: request.headers,\n});\n\nAdmin Plugin:\n\n// List users with pagination\nconst users = await auth.api.listUsers({\n  query: {\n    search: \"john\",\n    limit: 10,\n    offset: 0,\n    sortBy: \"createdAt\",\n    sortOrder: \"desc\",\n  },\n  headers: request.headers,\n});\n\n// Ban user\nawait auth.api.banUser({\n  body: {\n    userId: \"user_123\",\n    reason: \"Violation of ToS\",\n    expiresAt: new Date(\"2025-12-31\"),\n  },\n  headers: request.headers,\n});\n\n// Impersonate user (for admin support)\nconst impersonationSession = await auth.api.impersonateUser({\n  body: {\n    userId: \"user_123\",\n    expiresIn: 3600, // 1 hour\n  },\n  headers: request.headers,\n});"
      },
      {
        "title": "When to Use Which",
        "body": "Use CaseUse HTTP EndpointsUse auth.api.* MethodsClient-side auth✅ Yes❌ NoServer middleware❌ No✅ YesBackground jobs❌ No✅ YesAdmin dashboards✅ Yes (from client)✅ Yes (from server)Custom auth flows❌ No✅ YesMobile apps✅ Yes❌ NoAPI routes✅ Yes (proxy to handler)✅ Yes (direct calls)\n\nExample: Protected Route Middleware\n\nimport { Hono } from \"hono\";\nimport { createAuth } from \"./auth\";\nimport { createDatabase } from \"./db\";\n\nconst app = new Hono<{ Bindings: Env }>();\n\n// Middleware using server-side API\napp.use(\"/api/protected/*\", async (c, next) => {\n  const db = createDatabase(c.env.DB);\n  const auth = createAuth(db, c.env);\n\n  // Use server-side method\n  const session = await auth.api.getSession({\n    headers: c.req.raw.headers,\n  });\n\n  if (!session) {\n    return c.json({ error: \"Unauthorized\" }, 401);\n  }\n\n  // Attach to context\n  c.set(\"user\", session.user);\n  c.set(\"session\", session.session);\n\n  await next();\n});\n\n// Protected route\napp.get(\"/api/protected/profile\", async (c) => {\n  const user = c.get(\"user\");\n  return c.json({ user });\n});"
      },
      {
        "title": "Discovering Available Endpoints",
        "body": "Use the OpenAPI plugin to see all endpoints in your configuration:\n\nimport { betterAuth } from \"better-auth\";\nimport { openAPI } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  database: /* ... */,\n  plugins: [\n    openAPI(), // Adds /api/auth/reference endpoint\n  ],\n});\n\nInteractive documentation: Visit http://localhost:8787/api/auth/reference\n\nThis shows a Scalar UI with:\n\n✅ All available endpoints grouped by feature\n✅ Request/response schemas with types\n✅ Try-it-out functionality (test endpoints in browser)\n✅ Authentication requirements\n✅ Code examples in multiple languages\n\nProgrammatic access:\n\nconst schema = await auth.api.generateOpenAPISchema();\nconsole.log(JSON.stringify(schema, null, 2));\n// Returns full OpenAPI 3.0 spec"
      },
      {
        "title": "Quantified Time Savings",
        "body": "Building from scratch (manual implementation):\n\nCore auth endpoints (sign-up, sign-in, OAuth, sessions): 40 hours\nEmail verification & password reset: 10 hours\n2FA system (TOTP, backup codes, email OTP): 20 hours\nOrganizations (teams, invitations, RBAC): 60 hours\nAdmin panel (user management, impersonation): 30 hours\nTesting & debugging: 50 hours\nSecurity hardening: 20 hours\n\nTotal manual effort: ~220 hours (5.5 weeks full-time)\n\nWith better-auth:\n\nInitial setup: 2-4 hours\nCustomization & styling: 2-4 hours\n\nTotal with better-auth: 4-8 hours\n\nSavings: ~97% development time"
      },
      {
        "title": "Key Takeaway",
        "body": "better-auth provides 80+ production-ready endpoints covering:\n\n✅ Core authentication (20 endpoints)\n✅ 2FA & passwordless (15 endpoints)\n✅ Organizations & teams (35 endpoints)\n✅ Admin & user management (15 endpoints)\n✅ Social OAuth (auto-configured callbacks)\n✅ OpenAPI documentation (interactive UI)\n\nYou write zero endpoint code. Just configure features and call auth.handler()."
      },
      {
        "title": "Issue 1: \"d1Adapter is not exported\" Error",
        "body": "Problem: Code shows import { d1Adapter } from 'better-auth/adapters/d1' but this doesn't exist.\n\nSymptoms: TypeScript error or runtime error about missing export.\n\nSolution: Use Drizzle or Kysely instead:\n\n// ❌ WRONG - This doesn't exist\nimport { d1Adapter } from 'better-auth/adapters/d1'\ndatabase: d1Adapter(env.DB)\n\n// ✅ CORRECT - Use Drizzle\nimport { drizzleAdapter } from 'better-auth/adapters/drizzle'\nimport { drizzle } from 'drizzle-orm/d1'\nconst db = drizzle(env.DB, { schema })\ndatabase: drizzleAdapter(db, { provider: \"sqlite\" })\n\n// ✅ CORRECT - Use Kysely\nimport { Kysely } from 'kysely'\nimport { D1Dialect } from 'kysely-d1'\ndatabase: {\n  db: new Kysely({ dialect: new D1Dialect({ database: env.DB }) }),\n  type: \"sqlite\"\n}\n\nSource: Verified from 4 production repositories using better-auth + D1"
      },
      {
        "title": "Issue 2: Schema Generation Fails",
        "body": "Problem: npx better-auth migrate doesn't create D1-compatible schema.\n\nSymptoms: Migration SQL has wrong syntax or doesn't work with D1.\n\nSolution: Use Drizzle Kit to generate migrations:\n\n# Generate migration from Drizzle schema\nnpx drizzle-kit generate\n\n# Apply to D1\nwrangler d1 migrations apply my-app-db --remote\n\nWhy: Drizzle Kit generates SQLite-compatible SQL that works with D1."
      },
      {
        "title": "Issue 3: \"CamelCase\" vs \"snake_case\" Column Mismatch",
        "body": "Problem: Database has email_verified but better-auth expects emailVerified.\n\nSymptoms: Session reads fail, user data missing fields.\n\n⚠️ 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.\n\nSolution for Drizzle: Define schema with camelCase from the start (as shown in examples).\n\nSolution for Kysely with CamelCasePlugin: Use separate Kysely instance without CamelCasePlugin for better-auth:\n\n// DB for better-auth (no CamelCasePlugin)\nconst authDb = new Kysely({\n  dialect: new D1Dialect({ database: env.DB }),\n})\n\n// DB for app queries (with CamelCasePlugin)\nconst appDb = new Kysely({\n  dialect: new D1Dialect({ database: env.DB }),\n  plugins: [new CamelCasePlugin()],\n})\n\nexport const auth = betterAuth({\n  database: { db: authDb, type: \"sqlite\" },\n})\n\nSource: GitHub Issue #7136"
      },
      {
        "title": "Issue 4: D1 Eventual Consistency",
        "body": "Problem: Session reads immediately after write return stale data.\n\nSymptoms: User logs in but getSession() returns null on next request.\n\nSolution: Use Cloudflare KV for session storage (strong consistency):\n\nimport { betterAuth } from \"better-auth\";\n\nexport function createAuth(db: Database, env: Env) {\n  return betterAuth({\n    database: drizzleAdapter(db, { provider: \"sqlite\" }),\n    session: {\n      storage: {\n        get: async (sessionId) => {\n          const session = await env.SESSIONS_KV.get(sessionId);\n          return session ? JSON.parse(session) : null;\n        },\n        set: async (sessionId, session, ttl) => {\n          await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), {\n            expirationTtl: ttl,\n          });\n        },\n        delete: async (sessionId) => {\n          await env.SESSIONS_KV.delete(sessionId);\n        },\n      },\n    },\n  });\n}\n\nAdd to wrangler.toml:\n\n[[kv_namespaces]]\nbinding = \"SESSIONS_KV\"\nid = \"your-kv-namespace-id\""
      },
      {
        "title": "Issue 5: CORS Errors for SPA Applications",
        "body": "Problem: CORS errors when auth API is on different origin than frontend.\n\nSymptoms: Access-Control-Allow-Origin errors in browser console.\n\nSolution: Configure CORS headers in Worker and ensure trustedOrigins match:\n\nimport { cors } from \"hono/cors\";\n\n// CRITICAL: Both must match frontend origin exactly\napp.use(\n  \"/api/auth/*\",\n  cors({\n    origin: \"http://localhost:5173\", // Frontend URL (no trailing slash)\n    credentials: true, // Allow cookies\n    allowMethods: [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"],\n  })\n);\n\n// And in better-auth config\nexport const auth = betterAuth({\n  trustedOrigins: [\"http://localhost:5173\"], // Same as CORS origin\n  // ...\n});\n\nCommon Mistakes:\n\nTypo in origin URL (trailing slash, http vs https, wrong port)\nMismatched origins between CORS config and trustedOrigins\nCORS middleware registered AFTER auth routes (must be before)\n\nSource: GitHub Issue #7434"
      },
      {
        "title": "Issue 6: OAuth Redirect URI Mismatch",
        "body": "Problem: Social sign-in fails with \"redirect_uri_mismatch\" error.\n\nSymptoms: Google/GitHub OAuth returns error after user consent.\n\nSolution: Ensure exact match in OAuth provider settings:\n\nProvider setting: https://yourdomain.com/api/auth/callback/google\nbetter-auth URL:  https://yourdomain.com/api/auth/callback/google\n\n❌ Wrong: http vs https, trailing slash, subdomain mismatch\n✅ Right: Exact character-for-character match\n\nCheck better-auth callback URL:\n\n// It's always: {baseURL}/api/auth/callback/{provider}\nconst callbackURL = `${env.BETTER_AUTH_URL}/api/auth/callback/google`;\nconsole.log(\"Configure this URL in Google Console:\", callbackURL);"
      },
      {
        "title": "Issue 7: Missing Dependencies",
        "body": "Problem: TypeScript errors or runtime errors about missing packages.\n\nSymptoms: Cannot find module 'drizzle-orm' or similar.\n\nSolution: Install all required packages:\n\nFor Drizzle approach:\n\nnpm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types\n\nFor Kysely approach:\n\nnpm install better-auth kysely kysely-d1 @cloudflare/workers-types"
      },
      {
        "title": "Issue 8: Email Verification Not Sending",
        "body": "Problem: Email verification links never arrive.\n\nSymptoms: User signs up, but no email received.\n\nSolution: Implement sendVerificationEmail handler:\n\nexport const auth = betterAuth({\n  database: /* ... */,\n  emailAndPassword: {\n    enabled: true,\n    requireEmailVerification: true,\n  },\n  emailVerification: {\n    sendVerificationEmail: async ({ user, url }) => {\n      // Use your email service (SendGrid, Resend, etc.)\n      await sendEmail({\n        to: user.email,\n        subject: \"Verify your email\",\n        html: `\n          <p>Click the link below to verify your email:</p>\n          <a href=\"${url}\">Verify Email</a>\n        `,\n      });\n    },\n    sendOnSignUp: true,\n    autoSignInAfterVerification: true,\n    expiresIn: 3600, // 1 hour\n  },\n});\n\nFor Cloudflare: Use Cloudflare Email Routing or external service (Resend, SendGrid)."
      },
      {
        "title": "Issue 9: Session Expires Too Quickly",
        "body": "Problem: Session expires unexpectedly or never expires.\n\nSymptoms: User logged out unexpectedly or session persists after logout.\n\nSolution: Configure session expiration:\n\nexport const auth = betterAuth({\n  database: /* ... */,\n  session: {\n    expiresIn: 60 * 60 * 24 * 7, // 7 days (in seconds)\n    updateAge: 60 * 60 * 24, // Update session every 24 hours\n  },\n});"
      },
      {
        "title": "Issue 10: Social Provider Missing User Data",
        "body": "Problem: Social sign-in succeeds but missing user data (name, avatar).\n\nSymptoms: session.user.name is null after Google/GitHub sign-in.\n\nSolution: Request additional scopes:\n\nsocialProviders: {\n  google: {\n    clientId: env.GOOGLE_CLIENT_ID,\n    clientSecret: env.GOOGLE_CLIENT_SECRET,\n    scope: [\"openid\", \"email\", \"profile\"], // Include 'profile' for name/image\n  },\n  github: {\n    clientId: env.GITHUB_CLIENT_ID,\n    clientSecret: env.GITHUB_CLIENT_SECRET,\n    scope: [\"user:email\", \"read:user\"], // 'read:user' for full profile\n  },\n}"
      },
      {
        "title": "Issue 11: TypeScript Errors with Drizzle Schema",
        "body": "Problem: TypeScript complains about schema types.\n\nSymptoms: Type 'DrizzleD1Database' is not assignable to...\n\nSolution: Export proper types from database:\n\n// src/db/index.ts\nimport { drizzle, type DrizzleD1Database } from \"drizzle-orm/d1\";\nimport * as schema from \"./schema\";\n\nexport type Database = DrizzleD1Database<typeof schema>;\n\nexport function createDatabase(d1: D1Database): Database {\n  return drizzle(d1, { schema });\n}"
      },
      {
        "title": "Issue 12: Wrangler Dev Mode Not Working",
        "body": "Problem: wrangler dev fails with database errors.\n\nSymptoms: \"Database not found\" or migration errors in local dev.\n\nSolution: Apply migrations locally first:\n\n# Apply migrations to local D1\nwrangler d1 migrations apply my-app-db --local\n\n# Then run dev server\nwrangler dev"
      },
      {
        "title": "Issue 13: User Data Updates Not Reflecting in UI (with TanStack Query)",
        "body": "Problem: After updating user data (e.g., avatar, name), changes don't appear in useSession() despite calling queryClient.invalidateQueries().\n\nSymptoms: 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.\n\nRoot 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.\n\nSolution: Manually notify the nanostore after updating user data:\n\n// Update user data\nconst { data, error } = await authClient.updateUser({\n  image: newAvatarUrl,\n  name: newName\n})\n\nif (!error) {\n  // Manually invalidate better-auth session state\n  authClient.$store.notify('$sessionSignal')\n\n  // Optional: Also invalidate React Query if using it for other data\n  queryClient.invalidateQueries({ queryKey: ['user-profile'] })\n}\n\nWhen to use:\n\nUsing better-auth + TanStack Query together\nUpdating user profile fields (name, image, email)\nAny operation that modifies session user data client-side\n\nAlternative: Call refetch() from useSession(), but $store.notify() is more direct:\n\nconst { data: session, refetch } = authClient.useSession()\n// After update\nawait refetch()\n\nNote: $store is an undocumented internal API. This pattern is production-validated but may change in future better-auth versions.\n\nSource: Community-discovered pattern, production use verified"
      },
      {
        "title": "Issue 14: apiKey Table Schema Mismatch with D1",
        "body": "Problem: better-auth CLI (npx @better-auth/cli generate) fails with \"Failed to initialize database adapter\" when using D1.\n\nSymptoms: CLI cannot connect to D1 to introspect schema. Running migrations through CLI doesn't work.\n\nRoot Cause: The CLI expects a direct SQLite connection, but D1 requires Cloudflare's binding API.\n\nSolution: Skip the CLI and create migrations manually using the documented apiKey schema:\n\nCREATE TABLE api_key (\n  id TEXT PRIMARY KEY NOT NULL,\n  user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,\n  name TEXT,\n  start TEXT,\n  prefix TEXT,\n  key TEXT NOT NULL,\n  enabled INTEGER DEFAULT 1,\n  rate_limit_enabled INTEGER,\n  rate_limit_time_window INTEGER,\n  rate_limit_max INTEGER,\n  request_count INTEGER DEFAULT 0,\n  last_request INTEGER,\n  remaining INTEGER,\n  refill_interval INTEGER,\n  refill_amount INTEGER,\n  last_refill_at INTEGER,\n  expires_at INTEGER,\n  permissions TEXT,\n  metadata TEXT,\n  created_at INTEGER NOT NULL,\n  updated_at INTEGER NOT NULL\n);\n\nKey Points:\n\nThe table has exactly 21 columns (as of better-auth v1.4+)\nColumn names use snake_case (e.g., rate_limit_time_window, not rateLimitTimeWindow)\nD1 doesn't support ALTER TABLE DROP COLUMN - if schema drifts, use fresh migration pattern (drop and recreate tables)\nIn Drizzle adapter config, use apikey (lowercase) as the table name mapping\n\nFresh Migration Pattern for D1:\n\n-- Drop in reverse dependency order\nDROP TABLE IF EXISTS api_key;\nDROP TABLE IF EXISTS session;\n-- ... other tables\n\n-- Recreate with clean schema\nCREATE TABLE api_key (...);\n\nSource: Production debugging with D1 + better-auth apiKey plugin"
      },
      {
        "title": "Issue 15: Admin Plugin Requires DB Role (Dual-Auth)",
        "body": "Problem: Admin plugin methods like listUsers fail with \"You are not allowed to list users\" even though your middleware passes.\n\nSymptoms: Custom requireAdmin middleware (checking ADMIN_EMAILS env var) passes, but auth.api.listUsers() returns 403.\n\nRoot Cause: better-auth admin plugin has two authorization layers:\n\nYour middleware - Custom check (e.g., ADMIN_EMAILS)\nbetter-auth internal - Checks user.role === 'admin' in database\n\nBoth must pass for admin plugin methods to work.\n\nSolution: Set user role to 'admin' in the database:\n\n-- Fix for existing users\nUPDATE user SET role = 'admin' WHERE email = 'admin@example.com';\n\nOr use the admin UI/API to set roles after initial setup.\n\nWhy: The admin plugin's listUsers, banUser, impersonateUser, etc. all check user.role in the database, not your custom middleware logic.\n\nSource: Production debugging - misleading error message led to root cause discovery via wrangler tail"
      },
      {
        "title": "Issue 16: Organization/Team updated_at Must Be Nullable",
        "body": "Problem: Organization creation fails with SQL constraint error even though API returns \"slug already exists\".\n\nSymptoms:\n\nError message says \"An organization with this slug already exists\"\nDatabase table is actually empty\nwrangler tail shows: Failed query: insert into \"organization\" ... values (?, ?, ?, null, null, ?, null)\n\nRoot Cause: better-auth inserts null for updated_at on creation (only sets it on updates). If your schema has NOT NULL constraint, insert fails.\n\nSolution: Make updated_at nullable in both schema and migrations:\n\n// Drizzle schema - CORRECT\nexport const organization = sqliteTable('organization', {\n  // ...\n  updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()\n});\n\nexport const team = sqliteTable('team', {\n  // ...\n  updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()\n});\n\n-- Migration - CORRECT\nCREATE TABLE organization (\n  -- ...\n  updated_at INTEGER  -- No NOT NULL\n);\n\nApplies to: organization and team tables (possibly other plugin tables)\n\nSource: Production debugging - wrangler tail revealed actual SQL error behind misleading \"slug exists\" message"
      },
      {
        "title": "Issue 17: API Response Double-Nesting (listMembers, etc.)",
        "body": "Problem: Custom API endpoints return double-nested data like { members: { members: [...], total: N } }.\n\nSymptoms: UI shows \"undefined\" for counts, empty lists despite data existing.\n\nRoot Cause: better-auth methods like listMembers return { members: [...], total: N }. Wrapping with c.json({ members: result }) creates double nesting.\n\nSolution: Extract the array from better-auth response:\n\n// ❌ WRONG - Double nesting\nconst result = await auth.api.listMembers({ ... });\nreturn c.json({ members: result });\n// Returns: { members: { members: [...], total: N } }\n\n// ✅ CORRECT - Extract array\nconst result = await auth.api.listMembers({ ... });\nconst members = result?.members || [];\nreturn c.json({ members });\n// Returns: { members: [...] }\n\nAffected methods (return objects, not arrays):\n\nlistMembers → { members: [...], total: N }\nlistUsers → { users: [...], total: N, limit: N }\nlistOrganizations → { organizations: [...] } (check structure)\nlistInvitations → { invitations: [...] }\n\nPattern: Always check better-auth method return types before wrapping in your API response.\n\nSource: Production debugging - UI showed \"undefined\" count, API inspection revealed nesting issue"
      },
      {
        "title": "Issue 18: Expo Client fromJSONSchema Crash (v1.4.16)",
        "body": "Problem: Importing expoClient from @better-auth/expo/client crashes with TypeError: Cannot read property 'fromJSONSchema' of undefined on v1.4.16.\n\nSymptoms: Runtime crash immediately when importing expoClient in React Native/Expo apps.\n\nRoot Cause: Regression introduced after PR #6933 (cookie-based OAuth state fix for Expo). One of 3 commits after f4a9f15 broke the build.\n\nSolution:\n\nTemporary: Use continuous build at commit f4a9f15 (pre-regression)\nPermanent: Wait for fix (issue #7491 open as of 2026-01-20)\n\n// Crashes on v1.4.16\nimport { expoClient } from '@better-auth/expo/client'\n\n// Workaround: Use continuous build at f4a9f15\n// Or wait for fix in next release\n\nSource: GitHub Issue #7491"
      },
      {
        "title": "Issue 19: additionalFields string[] Returns Stringified JSON",
        "body": "Problem: After v1.4.12, additionalFields with type: 'string[]' return stringified arrays ('[\"a\",\"b\"]') instead of native arrays when querying via Drizzle directly.\n\nSymptoms: user.notificationTokens is a string, not an array. Code expecting arrays breaks.\n\nRoot 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.\n\nSolution:\n\nUse better-auth internalAdapter instead of querying Drizzle directly (has transformer)\nChange Drizzle schema to .jsonb() for string[] fields\nManually parse JSON strings until fixed\n\n// Config\nadditionalFields: {\n  notificationTokens: {\n    type: 'string[]',\n    required: true,\n    input: true,\n  },\n}\n\n// Create user\nnotificationTokens: ['token1', 'token2']\n\n// Result in DB (when querying via Drizzle directly)\n// '[\"token1\",\"token2\"]' (string, not array)\n\nSource: GitHub Issue #7440"
      },
      {
        "title": "Issue 20: additionalFields \"returned\" Property Blocks Input",
        "body": "Problem: Setting returned: false on additionalFields prevents field from being saved via API, even with input: true.\n\nSymptoms: Field never saved to database when creating/updating via API endpoints.\n\nRoot 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.\n\nSolution:\n\nDon't use returned: false if you need API write access\nWrite via server-side methods (auth.api.*) instead\n\n// Organization plugin config\nadditionalFields: {\n  secretField: {\n    type: 'string',\n    required: true,\n    input: true,      // Should allow API writes\n    returned: false,  // Should only block reads, but blocks writes too\n  },\n}\n\n// API request to create organization\n// secretField is never saved to database\n\nSource: GitHub Issue #7489"
      },
      {
        "title": "Issue 21: freshAge Based on Creation Time, Not Activity",
        "body": "Problem: session.freshAge checks time-since-creation, NOT recent activity. Active sessions become \"not fresh\" after freshAge elapses, even if used constantly.\n\nSymptoms: \"Fresh session required\" endpoints reject valid active sessions.\n\nWhy 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.\n\nSolution:\n\nSet updateAge <= freshAge to ensure freshness is updated before expiry\nAvoid \"fresh session required\" gating for long-lived sessions\nAccept as design: freshAge is strictly time-since-creation (maintainer confirmed)\n\n// Config\nsession: {\n  expiresIn: 60 * 60 * 24 * 7,    // 7 days\n  freshAge: 60 * 60 * 24,          // 24 hours\n  updateAge: 60 * 60 * 24 * 3,     // 3 days (> freshAge!) ⚠️ PROBLEM\n\n  // CORRECT - updateAge <= freshAge\n  updateAge: 60 * 60 * 12,         // 12 hours (< freshAge)\n}\n\n// Timeline with bad config:\n// T+0h: User signs in (createdAt = now)\n// T+12h: User makes requests (session active, still fresh)\n// T+25h: User makes request (session active, BUT NOT FRESH - freshAge elapsed)\n// Result: \"Fresh session required\" endpoints reject active session\n\nSource: GitHub Issue #7472"
      },
      {
        "title": "Issue 22: OAuth Token Endpoints Return Wrapped JSON",
        "body": "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.\n\nSymptoms: OAuth clients fail with Bearer undefined or invalid_token.\n\nRoot Cause: The endpoint pipeline returns { response, headers, status } for internal use, which gets serialized directly for HTTP requests. This breaks OAuth/OIDC spec requirements.\n\nSolution:\n\nTemporary: Manually unwrap .response field on client\nPermanent: Wait for fix (issue #7355 open, accepting contributions)\n\n// Expected (spec-compliant)\n{ \"access_token\": \"...\", \"token_type\": \"bearer\", \"expires_in\": 3600 }\n\n// Actual (wrapped)\n{ \"response\": { \"access_token\": \"...\", \"token_type\": \"bearer\", \"expires_in\": 3600 } }\n\n// Result: OAuth clients fail to parse, send `Bearer undefined`\n\nSource: GitHub Issue #7355"
      },
      {
        "title": "From Clerk",
        "body": "Key differences:\n\nClerk: Third-party service → better-auth: Self-hosted\nClerk: Proprietary → better-auth: Open source\nClerk: Monthly cost → better-auth: Free\n\nMigration steps:\n\nExport user data from Clerk (CSV or API)\nImport into better-auth database:\n// migration script\nconst clerkUsers = await fetchClerkUsers();\n\nfor (const clerkUser of clerkUsers) {\n  await db.insert(user).values({\n    id: clerkUser.id,\n    email: clerkUser.email,\n    emailVerified: clerkUser.email_verified,\n    name: clerkUser.first_name + \" \" + clerkUser.last_name,\n    image: clerkUser.profile_image_url,\n  });\n}\n\n\nReplace Clerk SDK with better-auth client:\n// Before (Clerk)\nimport { useUser } from \"@clerk/nextjs\";\nconst { user } = useUser();\n\n// After (better-auth)\nimport { authClient } from \"@/lib/auth-client\";\nconst { data: session } = authClient.useSession();\nconst user = session?.user;\n\n\nUpdate middleware for session verification\nConfigure social providers (same OAuth apps, different config)"
      },
      {
        "title": "From Auth.js (NextAuth)",
        "body": "Key differences:\n\nAuth.js: Limited features → better-auth: Comprehensive (2FA, orgs, etc.)\nAuth.js: Callbacks-heavy → better-auth: Plugin-based\nAuth.js: Session handling varies → better-auth: Consistent\n\nMigration steps:\n\nDatabase schema: Auth.js and better-auth use similar schemas, but column names differ\nReplace configuration:\n// Before (Auth.js)\nimport NextAuth from \"next-auth\";\nimport GoogleProvider from \"next-auth/providers/google\";\n\nexport default NextAuth({\n  providers: [GoogleProvider({ /* ... */ })],\n});\n\n// After (better-auth)\nimport { betterAuth } from \"better-auth\";\n\nexport const auth = betterAuth({\n  socialProviders: {\n    google: { /* ... */ },\n  },\n});\n\n\nUpdate client hooks:\n// Before\nimport { useSession } from \"next-auth/react\";\n\n// After\nimport { authClient } from \"@/lib/auth-client\";\nconst { data: session } = authClient.useSession();"
      },
      {
        "title": "Official Documentation",
        "body": "Homepage: https://better-auth.com\nIntroduction: https://www.better-auth.com/docs/introduction\nInstallation: https://www.better-auth.com/docs/installation\nBasic Usage: https://www.better-auth.com/docs/basic-usage"
      },
      {
        "title": "Core Concepts",
        "body": "Session Management: https://www.better-auth.com/docs/concepts/session-management\nUsers & Accounts: https://www.better-auth.com/docs/concepts/users-accounts\nClient SDK: https://www.better-auth.com/docs/concepts/client\nPlugins System: https://www.better-auth.com/docs/concepts/plugins"
      },
      {
        "title": "Authentication Methods",
        "body": "Email & Password: https://www.better-auth.com/docs/authentication/email-password\nOAuth Providers: https://www.better-auth.com/docs/concepts/oauth"
      },
      {
        "title": "Plugin Documentation",
        "body": "Core Plugins:\n\n2FA (Two-Factor): https://www.better-auth.com/docs/plugins/2fa\nOrganization: https://www.better-auth.com/docs/plugins/organization\nAdmin: https://www.better-auth.com/docs/plugins/admin\nMulti-Session: https://www.better-auth.com/docs/plugins/multi-session\nAPI Key: https://www.better-auth.com/docs/plugins/api-key\nGeneric OAuth: https://www.better-auth.com/docs/plugins/generic-oauth\n\nPasswordless Plugins:\n\nPasskey: https://www.better-auth.com/docs/plugins/passkey\nMagic Link: https://www.better-auth.com/docs/plugins/magic-link\nEmail OTP: https://www.better-auth.com/docs/plugins/email-otp\nPhone Number: https://www.better-auth.com/docs/plugins/phone-number\nAnonymous: https://www.better-auth.com/docs/plugins/anonymous\n\nAdvanced Plugins:\n\nUsername: https://www.better-auth.com/docs/plugins/username\nJWT: https://www.better-auth.com/docs/plugins/jwt\nOpenAPI: https://www.better-auth.com/docs/plugins/open-api\nOIDC Provider: https://www.better-auth.com/docs/plugins/oidc-provider\nSSO: https://www.better-auth.com/docs/plugins/sso\nStripe: https://www.better-auth.com/docs/plugins/stripe\nMCP: https://www.better-auth.com/docs/plugins/mcp"
      },
      {
        "title": "Framework Integrations",
        "body": "TanStack Start: https://www.better-auth.com/docs/integrations/tanstack\nExpo (React Native): https://www.better-auth.com/docs/integrations/expo"
      },
      {
        "title": "Community & Support",
        "body": "GitHub: https://github.com/better-auth/better-auth (22.4k ⭐)\nExamples: https://github.com/better-auth/better-auth/tree/main/examples\nDiscord: https://discord.gg/better-auth\nChangelog: https://github.com/better-auth/better-auth/releases"
      },
      {
        "title": "Related Documentation",
        "body": "Drizzle ORM: https://orm.drizzle.team/docs/get-started-sqlite\nKysely: https://kysely.dev/"
      },
      {
        "title": "Production Examples",
        "body": "Verified working D1 repositories (all use Drizzle or Kysely):\n\nzpg6/better-auth-cloudflare - Drizzle + D1 (includes CLI)\nzwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle + D1\nfoxlau/react-router-v7-better-auth - Drizzle + D1\nmatthewlynch/better-auth-react-router-cloudflare-d1 - Kysely + D1\n\nNone use a direct d1Adapter - all require Drizzle/Kysely."
      },
      {
        "title": "Version Compatibility",
        "body": "Tested with:\n\nbetter-auth@1.4.10\ndrizzle-orm@0.45.1\ndrizzle-kit@0.31.8\nkysely@0.28.9\nkysely-d1@0.4.0\n@cloudflare/workers-types@latest\nhono@4.11.3\nNode.js 18+, Bun 1.0+\n\nBreaking changes:\n\nv1.4.6: allowImpersonatingAdmins defaults to false\nv1.4.0: ESM-only (no CommonJS)\nv1.3.0: Multi-team table structure change\n\nCheck changelog: https://github.com/better-auth/better-auth/releases"
      },
      {
        "title": "Community Resources",
        "body": "Cloudflare-specific guides:\n\nzpg6/better-auth-cloudflare - Drizzle + D1 reference\nHono + better-auth on Cloudflare - Official Hono example\nReact Router + Cloudflare D1 - React Router v7 guide\nSvelteKit + Cloudflare D1 - SvelteKit guide\n\nToken Efficiency:\n\nWithout skill: ~35,000 tokens (D1 adapter errors, 15+ plugins, rate limiting, session caching, database hooks, mobile integration)\nWith skill: ~8,000 tokens (focused on errors + patterns + all plugins + API reference)\nSavings: ~77% (~27,000 tokens)\n\nErrors prevented: 22 documented issues with exact solutions\nKey value: D1 adapter requirement, nodejs_compat flag, OAuth 2.1 Provider, Bearer/OneTap/SCIM/Anonymous plugins, rate limiting, session caching, database hooks, Expo integration, 80+ endpoint reference, additionalFields bugs, freshAge behavior, OAuth token wrapping\n\nLast verified: 2026-01-21 | Skill version: 5.1.0 | Changes: Added 5 new issues from post-training-cutoff research (Expo fromJSONSchema crash, additionalFields string[] bug, additionalFields returned property bug, freshAge not activity-based, OAuth token wrapping). Expanded Issue #3 with Kysely CamelCasePlugin join parsing failure. Expanded Issue #5 with Hono CORS pattern. Added Cloudflare Workers DB binding constraints note. Added TanStack Start session nullability pattern. Updated to v1.4.16."
      }
    ],
    "body": "better-auth - D1 Adapter & Error Prevention Guide\n\nPackage: 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)\n\n⚠️ CRITICAL: D1 Adapter Requirement\n\nbetter-auth DOES NOT have d1Adapter(). You MUST use:\n\nDrizzle ORM (recommended): drizzleAdapter(db, { provider: \"sqlite\" })\nKysely: new Kysely({ dialect: new D1Dialect({ database: env.DB }) })\n\nSee Issue #1 below for details.\n\nWhat's New in v1.4.10 (Dec 31, 2025)\n\nMajor Features:\n\nOAuth 2.1 Provider plugin - Build your own OAuth provider (replaces MCP plugin)\nPatreon OAuth provider - Social sign-in with Patreon\nKick OAuth provider - With refresh token support\nVercel OAuth provider - Sign in with Vercel\nGlobal backgroundTasks config - Deferred actions for better performance\nForm data support - Email authentication with fetch metadata fallback\nStripe enhancements - Flexible subscription lifecycle, disableRedirect option\n\nAdmin Plugin Updates:\n\n⚠️ Breaking: Impersonation of admins disabled by default (v1.4.6)\nSupport role with permission-based user updates\nRole type inference improvements\n\nSecurity Fixes:\n\nSAML XML parser hardening with configurable size constraints\nSAML assertion timestamp validation with per-provider clock skew\nSSO domain-verified provider trust\nDeprecated algorithm rejection\nLine nonce enforcement\n\n📚 Docs: https://www.better-auth.com/changelogs\n\nWhat's New in v1.4.0 (Nov 22, 2025)\n\nMajor Features:\n\nStateless session management - Sessions without database storage\nESM-only package ⚠️ Breaking: CommonJS no longer supported\nJWT key rotation - Automatic key rotation for enhanced security\nSCIM provisioning - Enterprise user provisioning protocol\n@standard-schema/spec - Replaces ZodType for validation\nCaptchaFox integration - Built-in CAPTCHA support\nAutomatic server-side IP detection\nCookie-based account data storage\nMultiple passkey origins support\nRP-Initiated Logout endpoint (OIDC)\n\n📚 Docs: https://www.better-auth.com/changelogs\n\nWhat's New in v1.3 (July 2025)\n\nMajor Features:\n\nSSO with SAML 2.0 - Enterprise single sign-on (moved to separate @better-auth/sso package)\nMulti-team support ⚠️ Breaking: teamId removed from member table, new teamMembers table required\nAdditional fields - Custom fields for organization/member/invitation models\nPerformance improvements and bug fixes\n\n📚 Docs: https://www.better-auth.com/blog/1-3\n\nAlternative: Kysely Adapter Pattern\n\nIf you prefer Kysely over Drizzle:\n\nFile: src/auth.ts\n\nimport { betterAuth } from \"better-auth\";\nimport { Kysely, CamelCasePlugin } from \"kysely\";\nimport { D1Dialect } from \"kysely-d1\";\n\ntype Env = {\n  DB: D1Database;\n  BETTER_AUTH_SECRET: string;\n  // ... other env vars\n};\n\nexport function createAuth(env: Env) {\n  return betterAuth({\n    secret: env.BETTER_AUTH_SECRET,\n\n    // Kysely with D1Dialect\n    database: {\n      db: new Kysely({\n        dialect: new D1Dialect({\n          database: env.DB,\n        }),\n        plugins: [\n          // CRITICAL: Required if using Drizzle schema with snake_case\n          new CamelCasePlugin(),\n        ],\n      }),\n      type: \"sqlite\",\n    },\n\n    emailAndPassword: {\n      enabled: true,\n    },\n\n    // ... other config\n  });\n}\n\n\nWhy CamelCasePlugin?\n\nIf 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.\n\n⚠️ 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:\n\n// ❌ WRONG - DB binding not available outside request\nconst db = drizzle(env.DB, { schema }) // env.DB doesn't exist here\nexport const auth = betterAuth({ database: drizzleAdapter(db, { provider: \"sqlite\" }) })\n\n// ✅ CORRECT - Create auth instance per-request\nexport default {\n  fetch(request, env, ctx) {\n    const db = drizzle(env.DB, { schema })\n    const auth = betterAuth({ database: drizzleAdapter(db, { provider: \"sqlite\" }) })\n    return auth.handler(request)\n  }\n}\n\n\nCommunity Validation: Multiple production implementations confirm this pattern (Medium, AnswerOverflow, official Hono examples).\n\nFramework Integrations\nTanStack Start\n\n⚠️ CRITICAL: TanStack Start requires the reactStartCookies plugin to handle cookie setting properly.\n\nimport { betterAuth } from \"better-auth\";\nimport { drizzleAdapter } from \"better-auth/adapters/drizzle\";\nimport { reactStartCookies } from \"better-auth/react-start\";\n\nexport const auth = betterAuth({\n  database: drizzleAdapter(db, { provider: \"sqlite\" }),\n  plugins: [\n    twoFactor(),\n    organization(),\n    reactStartCookies(), // ⚠️ MUST be LAST plugin\n  ],\n});\n\n\nWhy 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.\n\nImportant: The reactStartCookies plugin must be the last plugin in the array.\n\nSession 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:\n\nconst { data: session } = authClient.useSession()\n\n// When NOT logged in:\nconsole.log(session) // { user: null, session: null }\nconsole.log(!!session) // true (unexpected!)\n\n// Correct check:\nif (session?.user) {\n  // User is logged in\n}\n\n\nAlways check session?.user or session?.session, not just session. This is expected behavior (session object container always exists).\n\nAPI Route Setup (/src/routes/api/auth/$.ts):\n\nimport { auth } from '@/lib/auth'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/api/auth/$')({\n  server: {\n    handlers: {\n      GET: ({ request }) => auth.handler(request),\n      POST: ({ request }) => auth.handler(request),\n    },\n  },\n})\n\n\n📚 Official Docs: https://www.better-auth.com/docs/integrations/tanstack\n\nAvailable Plugins (v1.4+)\n\nBetter Auth provides plugins for advanced authentication features:\n\nPlugin\tImport\tDescription\tDocs\nOAuth 2.1 Provider\tbetter-auth/plugins\tBuild OAuth 2.1 provider with PKCE, JWT tokens, consent flows (replaces MCP & OIDC plugins)\t📚\nSSO\tbetter-auth/plugins\tEnterprise Single Sign-On with OIDC, OAuth2, and SAML 2.0 support\t📚\nStripe\tbetter-auth/plugins\tPayment and subscription management with flexible lifecycle handling\t📚\nMCP\tbetter-auth/plugins\t⚠️ Deprecated - Use OAuth 2.1 Provider instead\t📚\nExpo\tbetter-auth/expo\tReact Native/Expo with webBrowserOptions and last-login-method tracking\t📚\nOAuth 2.1 Provider Plugin (New in v1.4.9)\n\nBuild your own OAuth provider for MCP servers, third-party apps, or API access:\n\nimport { betterAuth } from \"better-auth\";\nimport { oauthProvider } from \"better-auth/plugins\";\nimport { jwt } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  plugins: [\n    jwt(), // Required for token signing\n    oauthProvider({\n      // Token expiration (seconds)\n      accessTokenExpiresIn: 3600,      // 1 hour\n      refreshTokenExpiresIn: 2592000,  // 30 days\n      authorizationCodeExpiresIn: 600, // 10 minutes\n    }),\n  ],\n});\n\n\nKey Features:\n\nOAuth 2.1 compliant - PKCE mandatory, S256 only, no implicit flow\nThree grant types: authorization_code, refresh_token, client_credentials\nJWT or opaque tokens - Configurable token format\nDynamic client registration - RFC 7591 compliant\nConsent management - Skip consent for trusted clients\nOIDC UserInfo endpoint - /oauth2/userinfo with scope-based claims\n\nRequired Well-Known Endpoints:\n\n// app/api/.well-known/oauth-authorization-server/route.ts\nexport async function GET() {\n  return Response.json({\n    issuer: process.env.BETTER_AUTH_URL,\n    authorization_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/authorize`,\n    token_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/token`,\n    // ... other metadata\n  });\n}\n\n\nCreate OAuth Client:\n\nconst client = await auth.api.createOAuthClient({\n  body: {\n    name: \"My MCP Server\",\n    redirectURLs: [\"https://claude.ai/callback\"],\n    type: \"public\", // or \"confidential\"\n  },\n});\n// Returns: { clientId, clientSecret (if confidential) }\n\n\n📚 Full Docs: https://www.better-auth.com/docs/plugins/oauth-provider\n\n⚠️ Note: This plugin is in active development and may not be suitable for production use yet.\n\nAdditional Plugins Reference\nPlugin\tDescription\tDocs\nBearer\tAPI token auth (alternative to cookies for APIs)\t📚\nOne Tap\tGoogle One Tap frictionless sign-in\t📚\nSCIM\tEnterprise user provisioning (SCIM 2.0)\t📚\nAnonymous\tGuest user access without PII\t📚\nUsername\tUsername-based sign-in (alternative to email)\t📚\nGeneric OAuth\tCustom OAuth providers with PKCE\t📚\nMulti-Session\tMultiple accounts in same browser\t📚\nAPI Key\tToken-based auth with rate limits\t📚\nBearer Token Plugin\n\nFor API-only authentication (mobile apps, CLI tools, third-party integrations):\n\nimport { bearer } from \"better-auth/plugins\";\nimport { bearerClient } from \"better-auth/client/plugins\";\n\n// Server\nexport const auth = betterAuth({\n  plugins: [bearer()],\n});\n\n// Client - Store token after sign-in\nconst { token } = await authClient.signIn.email({ email, password });\nlocalStorage.setItem(\"auth_token\", token);\n\n// Client - Configure fetch to include token\nconst authClient = createAuthClient({\n  plugins: [bearerClient()],\n  fetchOptions: {\n    auth: { type: \"Bearer\", token: () => localStorage.getItem(\"auth_token\") },\n  },\n});\n\nGoogle One Tap Plugin\n\nFrictionless single-tap sign-in for users already signed into Google:\n\nimport { oneTap } from \"better-auth/plugins\";\nimport { oneTapClient } from \"better-auth/client/plugins\";\n\n// Server\nexport const auth = betterAuth({\n  plugins: [oneTap()],\n});\n\n// Client\nauthClient.oneTap({\n  onSuccess: (session) => {\n    window.location.href = \"/dashboard\";\n  },\n});\n\n\nRequirement: Configure authorized JavaScript origins in Google Cloud Console.\n\nAnonymous Plugin\n\nGuest access without requiring email/password:\n\nimport { anonymous } from \"better-auth/plugins\";\n\n// Server\nexport const auth = betterAuth({\n  plugins: [\n    anonymous({\n      emailDomainName: \"anon.example.com\", // temp@{id}.anon.example.com\n      onLinkAccount: async ({ anonymousUser, newUser }) => {\n        // Migrate anonymous user data to linked account\n        await migrateUserData(anonymousUser.id, newUser.id);\n      },\n    }),\n  ],\n});\n\n// Client\nawait authClient.signIn.anonymous();\n// Later: user can link to real account via signIn.social/email\n\nGeneric OAuth Plugin\n\nAdd custom OAuth providers not in the built-in list:\n\nimport { genericOAuth } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  plugins: [\n    genericOAuth({\n      config: [\n        {\n          providerId: \"linear\",\n          clientId: env.LINEAR_CLIENT_ID,\n          clientSecret: env.LINEAR_CLIENT_SECRET,\n          discoveryUrl: \"https://linear.app/.well-known/openid-configuration\",\n          scopes: [\"openid\", \"email\", \"profile\"],\n          pkce: true, // Recommended\n        },\n      ],\n    }),\n  ],\n});\n\n\nCallback URL pattern: {baseURL}/api/auth/oauth2/callback/{providerId}\n\nRate Limiting\n\nBuilt-in rate limiting with customizable rules:\n\nexport const auth = betterAuth({\n  rateLimit: {\n    window: 60,  // seconds (default: 60)\n    max: 100,    // requests per window (default: 100)\n\n    // Custom rules for sensitive endpoints\n    customRules: {\n      \"/sign-in/email\": { window: 10, max: 3 },\n      \"/two-factor/*\": { window: 10, max: 3 },\n      \"/forget-password\": { window: 60, max: 5 },\n    },\n\n    // Use Redis/KV for distributed systems\n    storage: \"secondary-storage\", // or \"database\"\n  },\n\n  // Secondary storage for rate limiting\n  secondaryStorage: {\n    get: async (key) => env.KV.get(key),\n    set: async (key, value, ttl) => env.KV.put(key, value, { expirationTtl: ttl }),\n    delete: async (key) => env.KV.delete(key),\n  },\n});\n\n\nNote: Server-side calls via auth.api.* bypass rate limiting.\n\nStateless Sessions (v1.4.0+)\n\nStore sessions entirely in signed cookies without database storage:\n\nexport const auth = betterAuth({\n  session: {\n    // Stateless: No database storage, session lives in cookie only\n    storage: undefined, // or omit entirely\n\n    // Cookie configuration\n    cookieCache: {\n      enabled: true,\n      maxAge: 60 * 60 * 24 * 7, // 7 days\n      encoding: \"jwt\", // Use JWT for stateless (not \"compact\")\n    },\n\n    // Session expiration\n    expiresIn: 60 * 60 * 24 * 7, // 7 days\n  },\n});\n\n\nWhen to Use:\n\nStorage Type\tUse Case\tTradeoffs\nStateless (cookie-only)\tRead-heavy apps, edge/serverless, no revocation needed\tCan't revoke sessions, limited payload size\nD1 Database\tFull session management, audit trails, revocation\tEventual consistency issues\nKV Storage\tStrong consistency, high read performance\tExtra binding setup\n\nKey Points:\n\nStateless sessions can't be revoked (user must wait for expiry)\nCookie size limit ~4KB (limits session data)\nUse encoding: \"jwt\" for interoperability, \"jwe\" for encrypted\nServer must have consistent BETTER_AUTH_SECRET across all instances\nJWT Key Rotation (v1.4.0+)\n\nAutomatically rotate JWT signing keys for enhanced security:\n\nimport { jwt } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  plugins: [\n    jwt({\n      // Key rotation (optional, enterprise security)\n      keyRotation: {\n        enabled: true,\n        rotationInterval: 60 * 60 * 24 * 30, // Rotate every 30 days\n        keepPreviousKeys: 3, // Keep 3 old keys for validation\n      },\n\n      // Custom signing algorithm (default: HS256)\n      algorithm: \"RS256\", // Requires asymmetric keys\n\n      // JWKS endpoint (auto-generated at /api/auth/jwks)\n      exposeJWKS: true,\n    }),\n  ],\n});\n\n\nKey Points:\n\nKey rotation prevents compromised key from having indefinite validity\nOld keys are kept temporarily to validate existing tokens\nJWKS endpoint at /api/auth/jwks for external services\nUse RS256 for public key verification (microservices)\nHS256 (default) for single-service apps\nProvider Scopes Reference\n\nCommon OAuth providers and the scopes needed for user data:\n\nProvider\tScope\tReturns\nGoogle\topenid\tUser ID only\n\temail\tEmail address, email_verified\n\tprofile\tName, avatar (picture), locale\nGitHub\tuser:email\tEmail address (may be private)\n\tread:user\tName, avatar, profile URL, bio\nMicrosoft\topenid\tUser ID only\n\temail\tEmail address\n\tprofile\tName, locale\n\tUser.Read\tFull profile from Graph API\nDiscord\tidentify\tUsername, avatar, discriminator\n\temail\tEmail address\nApple\tname\tFirst/last name (first auth only)\n\temail\tEmail or relay address\nPatreon\tidentity\tUser ID, name\n\tidentity[email]\tEmail address\nVercel\t(auto)\tEmail, name, avatar\n\nConfiguration Example:\n\nsocialProviders: {\n  google: {\n    clientId: env.GOOGLE_CLIENT_ID,\n    clientSecret: env.GOOGLE_CLIENT_SECRET,\n    scope: [\"openid\", \"email\", \"profile\"], // All user data\n  },\n  github: {\n    clientId: env.GITHUB_CLIENT_ID,\n    clientSecret: env.GITHUB_CLIENT_SECRET,\n    scope: [\"user:email\", \"read:user\"], // Email + full profile\n  },\n  microsoft: {\n    clientId: env.MS_CLIENT_ID,\n    clientSecret: env.MS_CLIENT_SECRET,\n    scope: [\"openid\", \"email\", \"profile\", \"User.Read\"],\n  },\n}\n\nSession Cookie Caching\n\nThree encoding strategies for session cookies:\n\nStrategy\tFormat\tUse Case\nCompact (default)\tBase64url + HMAC-SHA256\tSmallest, fastest\nJWT\tStandard JWT\tInteroperable\nJWE\tA256CBC-HS512 encrypted\tMost secure\nexport const auth = betterAuth({\n  session: {\n    cookieCache: {\n      enabled: true,\n      maxAge: 300, // 5 minutes\n      encoding: \"compact\", // or \"jwt\" or \"jwe\"\n    },\n    freshAge: 60 * 60 * 24, // 1 day - operations requiring fresh session\n  },\n});\n\n\nFresh sessions: Some sensitive operations require recently created sessions. Configure freshAge to control this window.\n\nNew Social Providers (v1.4.9+)\nsocialProviders: {\n  // Patreon - Creator economy\n  patreon: {\n    clientId: env.PATREON_CLIENT_ID,\n    clientSecret: env.PATREON_CLIENT_SECRET,\n    scope: [\"identity\", \"identity[email]\"],\n  },\n\n  // Kick - Streaming platform (with refresh tokens)\n  kick: {\n    clientId: env.KICK_CLIENT_ID,\n    clientSecret: env.KICK_CLIENT_SECRET,\n  },\n\n  // Vercel - Developer platform\n  vercel: {\n    clientId: env.VERCEL_CLIENT_ID,\n    clientSecret: env.VERCEL_CLIENT_SECRET,\n  },\n}\n\nCloudflare Workers Requirements\n\n⚠️ CRITICAL: Cloudflare Workers require AsyncLocalStorage support:\n\n# wrangler.toml\ncompatibility_flags = [\"nodejs_compat\"]\n# or for older Workers:\n# compatibility_flags = [\"nodejs_als\"]\n\n\nWithout this flag, better-auth will fail with context-related errors.\n\nDatabase Hooks\n\nExecute custom logic during database operations:\n\nexport const auth = betterAuth({\n  databaseHooks: {\n    user: {\n      create: {\n        before: async (user, ctx) => {\n          // Validate or modify before creation\n          if (user.email?.endsWith(\"@blocked.com\")) {\n            throw new APIError(\"BAD_REQUEST\", { message: \"Email domain not allowed\" });\n          }\n          return { data: { ...user, role: \"member\" } };\n        },\n        after: async (user, ctx) => {\n          // Send welcome email, create related records, etc.\n          await sendWelcomeEmail(user.email);\n          await createDefaultWorkspace(user.id);\n        },\n      },\n    },\n    session: {\n      create: {\n        after: async (session, ctx) => {\n          // Audit logging\n          await auditLog.create({ action: \"session_created\", userId: session.userId });\n        },\n      },\n    },\n  },\n});\n\n\nAvailable hooks: create, update for user, session, account, verification tables.\n\nExpo/React Native Integration\n\nComplete mobile integration pattern:\n\n// Client setup with secure storage\nimport { expoClient } from \"@better-auth/expo\";\nimport * as SecureStore from \"expo-secure-store\";\n\nconst authClient = createAuthClient({\n  baseURL: \"https://api.example.com\",\n  plugins: [expoClient({ storage: SecureStore })],\n});\n\n// OAuth with deep linking\nawait authClient.signIn.social({\n  provider: \"google\",\n  callbackURL: \"myapp://auth/callback\", // Deep link\n});\n\n// Or use ID token verification (no redirect)\nawait authClient.signIn.social({\n  provider: \"google\",\n  idToken: {\n    token: googleIdToken,\n    nonce: generatedNonce,\n  },\n});\n\n// Authenticated requests\nconst cookie = await authClient.getCookie();\nawait fetch(\"https://api.example.com/data\", {\n  headers: { Cookie: cookie },\n  credentials: \"omit\",\n});\n\n\napp.json deep link setup:\n\n{\n  \"expo\": {\n    \"scheme\": \"myapp\"\n  }\n}\n\n\nServer trustedOrigins (development):\n\ntrustedOrigins: [\"exp://**\", \"myapp://\"]\n\nAPI Reference\nOverview: What You Get For Free\n\nWhen 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.\n\nThis dual-layer API system means:\n\nClients (React, Vue, mobile apps) call HTTP endpoints directly\nServer-side code (middleware, background jobs) uses auth.api.* methods\nZero boilerplate - no need to write auth endpoints manually\n\nTime savings: Building this from scratch = ~220 hours. With better-auth = ~4-8 hours. 97% reduction.\n\nAuto-Generated HTTP Endpoints\n\nAll endpoints are automatically exposed at /api/auth/* when using auth.handler().\n\nCore Authentication Endpoints\nEndpoint\tMethod\tDescription\n/sign-up/email\tPOST\tRegister with email/password\n/sign-in/email\tPOST\tAuthenticate with email/password\n/sign-out\tPOST\tLogout user\n/change-password\tPOST\tUpdate password (requires current password)\n/forget-password\tPOST\tInitiate password reset flow\n/reset-password\tPOST\tComplete password reset with token\n/send-verification-email\tPOST\tSend email verification link\n/verify-email\tGET\tVerify email with token (?token=<token>)\n/get-session\tGET\tRetrieve current session\n/list-sessions\tGET\tGet all active user sessions\n/revoke-session\tPOST\tEnd specific session\n/revoke-other-sessions\tPOST\tEnd all sessions except current\n/revoke-sessions\tPOST\tEnd all user sessions\n/update-user\tPOST\tModify user profile (name, image)\n/change-email\tPOST\tUpdate email address\n/set-password\tPOST\tAdd password to OAuth-only account\n/delete-user\tPOST\tRemove user account\n/list-accounts\tGET\tGet linked authentication providers\n/link-social\tPOST\tConnect OAuth provider to account\n/unlink-account\tPOST\tDisconnect provider\nSocial OAuth Endpoints\nEndpoint\tMethod\tDescription\n/sign-in/social\tPOST\tInitiate OAuth flow (provider specified in body)\n/callback/:provider\tGET\tOAuth callback handler (e.g., /callback/google)\n/get-access-token\tGET\tRetrieve provider access token\n\nExample OAuth flow:\n\n// Client initiates\nawait authClient.signIn.social({\n  provider: \"google\",\n  callbackURL: \"/dashboard\",\n});\n\n// better-auth handles redirect to Google\n// Google redirects back to /api/auth/callback/google\n// better-auth creates session automatically\n\nPlugin Endpoints\nTwo-Factor Authentication (2FA Plugin)\nimport { twoFactor } from \"better-auth/plugins\";\n\nEndpoint\tMethod\tDescription\n/two-factor/enable\tPOST\tActivate 2FA for user\n/two-factor/disable\tPOST\tDeactivate 2FA\n/two-factor/get-totp-uri\tGET\tGet QR code URI for authenticator app\n/two-factor/verify-totp\tPOST\tValidate TOTP code from authenticator\n/two-factor/send-otp\tPOST\tSend OTP via email\n/two-factor/verify-otp\tPOST\tValidate email OTP\n/two-factor/generate-backup-codes\tPOST\tCreate recovery codes\n/two-factor/verify-backup-code\tPOST\tUse backup code for login\n/two-factor/view-backup-codes\tGET\tView current backup codes\n\n📚 Docs: https://www.better-auth.com/docs/plugins/2fa\n\nOrganization Plugin (Multi-Tenant SaaS)\nimport { organization } from \"better-auth/plugins\";\n\n\nOrganizations (10 endpoints):\n\nEndpoint\tMethod\tDescription\n/organization/create\tPOST\tCreate organization\n/organization/list\tGET\tList user's organizations\n/organization/get-full\tGET\tGet complete org details\n/organization/update\tPUT\tModify organization\n/organization/delete\tDELETE\tRemove organization\n/organization/check-slug\tGET\tVerify slug availability\n/organization/set-active\tPOST\tSet active organization context\n\nMembers (8 endpoints):\n\nEndpoint\tMethod\tDescription\n/organization/list-members\tGET\tGet organization members\n/organization/add-member\tPOST\tAdd member directly\n/organization/remove-member\tDELETE\tRemove member\n/organization/update-member-role\tPUT\tChange member role\n/organization/get-active-member\tGET\tGet current member info\n/organization/leave\tPOST\tLeave organization\n\nInvitations (7 endpoints):\n\nEndpoint\tMethod\tDescription\n/organization/invite-member\tPOST\tSend invitation email\n/organization/accept-invitation\tPOST\tAccept invite\n/organization/reject-invitation\tPOST\tReject invite\n/organization/cancel-invitation\tPOST\tCancel pending invite\n/organization/get-invitation\tGET\tGet invitation details\n/organization/list-invitations\tGET\tList org invitations\n/organization/list-user-invitations\tGET\tList user's pending invites\n\nTeams (8 endpoints):\n\nEndpoint\tMethod\tDescription\n/organization/create-team\tPOST\tCreate team within org\n/organization/list-teams\tGET\tList organization teams\n/organization/update-team\tPUT\tModify team\n/organization/remove-team\tDELETE\tRemove team\n/organization/set-active-team\tPOST\tSet active team context\n/organization/list-team-members\tGET\tList team members\n/organization/add-team-member\tPOST\tAdd member to team\n/organization/remove-team-member\tDELETE\tRemove team member\n\nPermissions & Roles (6 endpoints):\n\nEndpoint\tMethod\tDescription\n/organization/has-permission\tPOST\tCheck if user has permission\n/organization/create-role\tPOST\tCreate custom role\n/organization/delete-role\tDELETE\tDelete custom role\n/organization/list-roles\tGET\tList all roles\n/organization/get-role\tGET\tGet role details\n/organization/update-role\tPUT\tModify role permissions\n\n📚 Docs: https://www.better-auth.com/docs/plugins/organization\n\nAdmin Plugin\nimport { admin } from \"better-auth/plugins\";\n\n// v1.4.10 configuration options\nadmin({\n  defaultRole: \"user\",\n  adminRoles: [\"admin\"],\n  adminUserIds: [\"user_abc123\"], // Always grant admin to specific users\n  impersonationSessionDuration: 3600, // 1 hour (seconds)\n  allowImpersonatingAdmins: false, // ⚠️ Default changed in v1.4.6\n  defaultBanReason: \"Violation of Terms of Service\",\n  bannedUserMessage: \"Your account has been suspended\",\n})\n\nEndpoint\tMethod\tDescription\n/admin/create-user\tPOST\tCreate user as admin\n/admin/list-users\tGET\tList all users (with filters/pagination)\n/admin/set-role\tPOST\tAssign user role\n/admin/set-user-password\tPOST\tChange user password\n/admin/update-user\tPUT\tModify user details\n/admin/remove-user\tDELETE\tDelete user account\n/admin/ban-user\tPOST\tBan user account (with optional expiry)\n/admin/unban-user\tPOST\tUnban user\n/admin/list-user-sessions\tGET\tGet user's active sessions\n/admin/revoke-user-session\tDELETE\tEnd specific user session\n/admin/revoke-user-sessions\tDELETE\tEnd all user sessions\n/admin/impersonate-user\tPOST\tStart impersonating user\n/admin/stop-impersonating\tPOST\tEnd impersonation session\n\n⚠️ Breaking Change (v1.4.6): allowImpersonatingAdmins now defaults to false. Set to true explicitly if you need admin-on-admin impersonation.\n\nCustom Roles with Permissions (v1.4.10):\n\nimport { createAccessControl } from \"better-auth/plugins/access\";\n\n// Define resources and permissions\nconst ac = createAccessControl({\n  user: [\"create\", \"read\", \"update\", \"delete\", \"ban\", \"impersonate\"],\n  project: [\"create\", \"read\", \"update\", \"delete\", \"share\"],\n} as const);\n\n// Create custom roles\nconst supportRole = ac.newRole({\n  user: [\"read\", \"ban\"],      // Can view and ban users\n  project: [\"read\"],          // Can view projects\n});\n\nconst managerRole = ac.newRole({\n  user: [\"read\", \"update\"],\n  project: [\"create\", \"read\", \"update\", \"delete\"],\n});\n\n// Use in plugin\nadmin({\n  ac,\n  roles: {\n    support: supportRole,\n    manager: managerRole,\n  },\n})\n\n\n📚 Docs: https://www.better-auth.com/docs/plugins/admin\n\nOther Plugin Endpoints\n\nPasskey Plugin (5 endpoints) - Docs:\n\n/passkey/add, /sign-in/passkey, /passkey/list, /passkey/delete, /passkey/update\n\nMagic Link Plugin (2 endpoints) - Docs:\n\n/sign-in/magic-link, /magic-link/verify\n\nUsername Plugin (2 endpoints) - Docs:\n\n/sign-in/username, /username/is-available\n\nPhone Number Plugin (5 endpoints) - Docs:\n\n/sign-in/phone-number, /phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-password\n\nEmail OTP Plugin (6 endpoints) - Docs:\n\n/email-otp/send-verification-otp, /email-otp/check-verification-otp, /sign-in/email-otp, /email-otp/verify-email, /forget-password/email-otp, /email-otp/reset-password\n\nAnonymous Plugin (1 endpoint) - Docs:\n\n/sign-in/anonymous\n\nJWT Plugin (2 endpoints) - Docs:\n\n/token (get JWT), /jwks (public key for verification)\n\nOpenAPI Plugin (2 endpoints) - Docs:\n\n/reference (interactive API docs with Scalar UI)\n/generate-openapi-schema (get OpenAPI spec as JSON)\nServer-Side API Methods (auth.api.*)\n\nEvery HTTP endpoint has a corresponding server-side method. Use these for:\n\nServer-side middleware (protecting routes)\nBackground jobs (user cleanup, notifications)\nAdmin operations (bulk user management)\nCustom auth flows (programmatic session creation)\nCore API Methods\n// Authentication\nawait auth.api.signUpEmail({\n  body: { email, password, name },\n  headers: request.headers,\n});\n\nawait auth.api.signInEmail({\n  body: { email, password, rememberMe: true },\n  headers: request.headers,\n});\n\nawait auth.api.signOut({ headers: request.headers });\n\n// Session Management\nconst session = await auth.api.getSession({ headers: request.headers });\n\nawait auth.api.listSessions({ headers: request.headers });\n\nawait auth.api.revokeSession({\n  body: { token: \"session_token_here\" },\n  headers: request.headers,\n});\n\n// User Management\nawait auth.api.updateUser({\n  body: { name: \"New Name\", image: \"https://...\" },\n  headers: request.headers,\n});\n\nawait auth.api.changeEmail({\n  body: { newEmail: \"newemail@example.com\" },\n  headers: request.headers,\n});\n\nawait auth.api.deleteUser({\n  body: { password: \"current_password\" },\n  headers: request.headers,\n});\n\n// Account Linking\nawait auth.api.linkSocialAccount({\n  body: { provider: \"google\" },\n  headers: request.headers,\n});\n\nawait auth.api.unlinkAccount({\n  body: { providerId: \"google\", accountId: \"google_123\" },\n  headers: request.headers,\n});\n\nPlugin API Methods\n\n2FA Plugin:\n\n// Enable 2FA\nconst { totpUri, backupCodes } = await auth.api.enableTwoFactor({\n  body: { issuer: \"MyApp\" },\n  headers: request.headers,\n});\n\n// Verify TOTP code\nawait auth.api.verifyTOTP({\n  body: { code: \"123456\", trustDevice: true },\n  headers: request.headers,\n});\n\n// Generate backup codes\nconst { backupCodes } = await auth.api.generateBackupCodes({\n  headers: request.headers,\n});\n\n\nOrganization Plugin:\n\n// Create organization\nconst org = await auth.api.createOrganization({\n  body: { name: \"Acme Corp\", slug: \"acme\" },\n  headers: request.headers,\n});\n\n// Add member\nawait auth.api.addMember({\n  body: {\n    userId: \"user_123\",\n    role: \"admin\",\n    organizationId: org.id,\n  },\n  headers: request.headers,\n});\n\n// Check permissions\nconst hasPermission = await auth.api.hasPermission({\n  body: {\n    organizationId: org.id,\n    permission: \"users:delete\",\n  },\n  headers: request.headers,\n});\n\n\nAdmin Plugin:\n\n// List users with pagination\nconst users = await auth.api.listUsers({\n  query: {\n    search: \"john\",\n    limit: 10,\n    offset: 0,\n    sortBy: \"createdAt\",\n    sortOrder: \"desc\",\n  },\n  headers: request.headers,\n});\n\n// Ban user\nawait auth.api.banUser({\n  body: {\n    userId: \"user_123\",\n    reason: \"Violation of ToS\",\n    expiresAt: new Date(\"2025-12-31\"),\n  },\n  headers: request.headers,\n});\n\n// Impersonate user (for admin support)\nconst impersonationSession = await auth.api.impersonateUser({\n  body: {\n    userId: \"user_123\",\n    expiresIn: 3600, // 1 hour\n  },\n  headers: request.headers,\n});\n\nWhen to Use Which\nUse Case\tUse HTTP Endpoints\tUse auth.api.* Methods\nClient-side auth\t✅ Yes\t❌ No\nServer middleware\t❌ No\t✅ Yes\nBackground jobs\t❌ No\t✅ Yes\nAdmin dashboards\t✅ Yes (from client)\t✅ Yes (from server)\nCustom auth flows\t❌ No\t✅ Yes\nMobile apps\t✅ Yes\t❌ No\nAPI routes\t✅ Yes (proxy to handler)\t✅ Yes (direct calls)\n\nExample: Protected Route Middleware\n\nimport { Hono } from \"hono\";\nimport { createAuth } from \"./auth\";\nimport { createDatabase } from \"./db\";\n\nconst app = new Hono<{ Bindings: Env }>();\n\n// Middleware using server-side API\napp.use(\"/api/protected/*\", async (c, next) => {\n  const db = createDatabase(c.env.DB);\n  const auth = createAuth(db, c.env);\n\n  // Use server-side method\n  const session = await auth.api.getSession({\n    headers: c.req.raw.headers,\n  });\n\n  if (!session) {\n    return c.json({ error: \"Unauthorized\" }, 401);\n  }\n\n  // Attach to context\n  c.set(\"user\", session.user);\n  c.set(\"session\", session.session);\n\n  await next();\n});\n\n// Protected route\napp.get(\"/api/protected/profile\", async (c) => {\n  const user = c.get(\"user\");\n  return c.json({ user });\n});\n\nDiscovering Available Endpoints\n\nUse the OpenAPI plugin to see all endpoints in your configuration:\n\nimport { betterAuth } from \"better-auth\";\nimport { openAPI } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  database: /* ... */,\n  plugins: [\n    openAPI(), // Adds /api/auth/reference endpoint\n  ],\n});\n\n\nInteractive documentation: Visit http://localhost:8787/api/auth/reference\n\nThis shows a Scalar UI with:\n\n✅ All available endpoints grouped by feature\n✅ Request/response schemas with types\n✅ Try-it-out functionality (test endpoints in browser)\n✅ Authentication requirements\n✅ Code examples in multiple languages\n\nProgrammatic access:\n\nconst schema = await auth.api.generateOpenAPISchema();\nconsole.log(JSON.stringify(schema, null, 2));\n// Returns full OpenAPI 3.0 spec\n\nQuantified Time Savings\n\nBuilding from scratch (manual implementation):\n\nCore auth endpoints (sign-up, sign-in, OAuth, sessions): 40 hours\nEmail verification & password reset: 10 hours\n2FA system (TOTP, backup codes, email OTP): 20 hours\nOrganizations (teams, invitations, RBAC): 60 hours\nAdmin panel (user management, impersonation): 30 hours\nTesting & debugging: 50 hours\nSecurity hardening: 20 hours\n\nTotal manual effort: ~220 hours (5.5 weeks full-time)\n\nWith better-auth:\n\nInitial setup: 2-4 hours\nCustomization & styling: 2-4 hours\n\nTotal with better-auth: 4-8 hours\n\nSavings: ~97% development time\n\nKey Takeaway\n\nbetter-auth provides 80+ production-ready endpoints covering:\n\n✅ Core authentication (20 endpoints)\n✅ 2FA & passwordless (15 endpoints)\n✅ Organizations & teams (35 endpoints)\n✅ Admin & user management (15 endpoints)\n✅ Social OAuth (auto-configured callbacks)\n✅ OpenAPI documentation (interactive UI)\n\nYou write zero endpoint code. Just configure features and call auth.handler().\n\nKnown Issues & Solutions\nIssue 1: \"d1Adapter is not exported\" Error\n\nProblem: Code shows import { d1Adapter } from 'better-auth/adapters/d1' but this doesn't exist.\n\nSymptoms: TypeScript error or runtime error about missing export.\n\nSolution: Use Drizzle or Kysely instead:\n\n// ❌ WRONG - This doesn't exist\nimport { d1Adapter } from 'better-auth/adapters/d1'\ndatabase: d1Adapter(env.DB)\n\n// ✅ CORRECT - Use Drizzle\nimport { drizzleAdapter } from 'better-auth/adapters/drizzle'\nimport { drizzle } from 'drizzle-orm/d1'\nconst db = drizzle(env.DB, { schema })\ndatabase: drizzleAdapter(db, { provider: \"sqlite\" })\n\n// ✅ CORRECT - Use Kysely\nimport { Kysely } from 'kysely'\nimport { D1Dialect } from 'kysely-d1'\ndatabase: {\n  db: new Kysely({ dialect: new D1Dialect({ database: env.DB }) }),\n  type: \"sqlite\"\n}\n\n\nSource: Verified from 4 production repositories using better-auth + D1\n\nIssue 2: Schema Generation Fails\n\nProblem: npx better-auth migrate doesn't create D1-compatible schema.\n\nSymptoms: Migration SQL has wrong syntax or doesn't work with D1.\n\nSolution: Use Drizzle Kit to generate migrations:\n\n# Generate migration from Drizzle schema\nnpx drizzle-kit generate\n\n# Apply to D1\nwrangler d1 migrations apply my-app-db --remote\n\n\nWhy: Drizzle Kit generates SQLite-compatible SQL that works with D1.\n\nIssue 3: \"CamelCase\" vs \"snake_case\" Column Mismatch\n\nProblem: Database has email_verified but better-auth expects emailVerified.\n\nSymptoms: Session reads fail, user data missing fields.\n\n⚠️ 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.\n\nSolution for Drizzle: Define schema with camelCase from the start (as shown in examples).\n\nSolution for Kysely with CamelCasePlugin: Use separate Kysely instance without CamelCasePlugin for better-auth:\n\n// DB for better-auth (no CamelCasePlugin)\nconst authDb = new Kysely({\n  dialect: new D1Dialect({ database: env.DB }),\n})\n\n// DB for app queries (with CamelCasePlugin)\nconst appDb = new Kysely({\n  dialect: new D1Dialect({ database: env.DB }),\n  plugins: [new CamelCasePlugin()],\n})\n\nexport const auth = betterAuth({\n  database: { db: authDb, type: \"sqlite\" },\n})\n\n\nSource: GitHub Issue #7136\n\nIssue 4: D1 Eventual Consistency\n\nProblem: Session reads immediately after write return stale data.\n\nSymptoms: User logs in but getSession() returns null on next request.\n\nSolution: Use Cloudflare KV for session storage (strong consistency):\n\nimport { betterAuth } from \"better-auth\";\n\nexport function createAuth(db: Database, env: Env) {\n  return betterAuth({\n    database: drizzleAdapter(db, { provider: \"sqlite\" }),\n    session: {\n      storage: {\n        get: async (sessionId) => {\n          const session = await env.SESSIONS_KV.get(sessionId);\n          return session ? JSON.parse(session) : null;\n        },\n        set: async (sessionId, session, ttl) => {\n          await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), {\n            expirationTtl: ttl,\n          });\n        },\n        delete: async (sessionId) => {\n          await env.SESSIONS_KV.delete(sessionId);\n        },\n      },\n    },\n  });\n}\n\n\nAdd to wrangler.toml:\n\n[[kv_namespaces]]\nbinding = \"SESSIONS_KV\"\nid = \"your-kv-namespace-id\"\n\nIssue 5: CORS Errors for SPA Applications\n\nProblem: CORS errors when auth API is on different origin than frontend.\n\nSymptoms: Access-Control-Allow-Origin errors in browser console.\n\nSolution: Configure CORS headers in Worker and ensure trustedOrigins match:\n\nimport { cors } from \"hono/cors\";\n\n// CRITICAL: Both must match frontend origin exactly\napp.use(\n  \"/api/auth/*\",\n  cors({\n    origin: \"http://localhost:5173\", // Frontend URL (no trailing slash)\n    credentials: true, // Allow cookies\n    allowMethods: [\"GET\", \"POST\", \"PUT\", \"DELETE\", \"OPTIONS\"],\n  })\n);\n\n// And in better-auth config\nexport const auth = betterAuth({\n  trustedOrigins: [\"http://localhost:5173\"], // Same as CORS origin\n  // ...\n});\n\n\nCommon Mistakes:\n\nTypo in origin URL (trailing slash, http vs https, wrong port)\nMismatched origins between CORS config and trustedOrigins\nCORS middleware registered AFTER auth routes (must be before)\n\nSource: GitHub Issue #7434\n\nIssue 6: OAuth Redirect URI Mismatch\n\nProblem: Social sign-in fails with \"redirect_uri_mismatch\" error.\n\nSymptoms: Google/GitHub OAuth returns error after user consent.\n\nSolution: Ensure exact match in OAuth provider settings:\n\nProvider setting: https://yourdomain.com/api/auth/callback/google\nbetter-auth URL:  https://yourdomain.com/api/auth/callback/google\n\n❌ Wrong: http vs https, trailing slash, subdomain mismatch\n✅ Right: Exact character-for-character match\n\n\nCheck better-auth callback URL:\n\n// It's always: {baseURL}/api/auth/callback/{provider}\nconst callbackURL = `${env.BETTER_AUTH_URL}/api/auth/callback/google`;\nconsole.log(\"Configure this URL in Google Console:\", callbackURL);\n\nIssue 7: Missing Dependencies\n\nProblem: TypeScript errors or runtime errors about missing packages.\n\nSymptoms: Cannot find module 'drizzle-orm' or similar.\n\nSolution: Install all required packages:\n\nFor Drizzle approach:\n\nnpm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types\n\n\nFor Kysely approach:\n\nnpm install better-auth kysely kysely-d1 @cloudflare/workers-types\n\nIssue 8: Email Verification Not Sending\n\nProblem: Email verification links never arrive.\n\nSymptoms: User signs up, but no email received.\n\nSolution: Implement sendVerificationEmail handler:\n\nexport const auth = betterAuth({\n  database: /* ... */,\n  emailAndPassword: {\n    enabled: true,\n    requireEmailVerification: true,\n  },\n  emailVerification: {\n    sendVerificationEmail: async ({ user, url }) => {\n      // Use your email service (SendGrid, Resend, etc.)\n      await sendEmail({\n        to: user.email,\n        subject: \"Verify your email\",\n        html: `\n          <p>Click the link below to verify your email:</p>\n          <a href=\"${url}\">Verify Email</a>\n        `,\n      });\n    },\n    sendOnSignUp: true,\n    autoSignInAfterVerification: true,\n    expiresIn: 3600, // 1 hour\n  },\n});\n\n\nFor Cloudflare: Use Cloudflare Email Routing or external service (Resend, SendGrid).\n\nIssue 9: Session Expires Too Quickly\n\nProblem: Session expires unexpectedly or never expires.\n\nSymptoms: User logged out unexpectedly or session persists after logout.\n\nSolution: Configure session expiration:\n\nexport const auth = betterAuth({\n  database: /* ... */,\n  session: {\n    expiresIn: 60 * 60 * 24 * 7, // 7 days (in seconds)\n    updateAge: 60 * 60 * 24, // Update session every 24 hours\n  },\n});\n\nIssue 10: Social Provider Missing User Data\n\nProblem: Social sign-in succeeds but missing user data (name, avatar).\n\nSymptoms: session.user.name is null after Google/GitHub sign-in.\n\nSolution: Request additional scopes:\n\nsocialProviders: {\n  google: {\n    clientId: env.GOOGLE_CLIENT_ID,\n    clientSecret: env.GOOGLE_CLIENT_SECRET,\n    scope: [\"openid\", \"email\", \"profile\"], // Include 'profile' for name/image\n  },\n  github: {\n    clientId: env.GITHUB_CLIENT_ID,\n    clientSecret: env.GITHUB_CLIENT_SECRET,\n    scope: [\"user:email\", \"read:user\"], // 'read:user' for full profile\n  },\n}\n\nIssue 11: TypeScript Errors with Drizzle Schema\n\nProblem: TypeScript complains about schema types.\n\nSymptoms: Type 'DrizzleD1Database' is not assignable to...\n\nSolution: Export proper types from database:\n\n// src/db/index.ts\nimport { drizzle, type DrizzleD1Database } from \"drizzle-orm/d1\";\nimport * as schema from \"./schema\";\n\nexport type Database = DrizzleD1Database<typeof schema>;\n\nexport function createDatabase(d1: D1Database): Database {\n  return drizzle(d1, { schema });\n}\n\nIssue 12: Wrangler Dev Mode Not Working\n\nProblem: wrangler dev fails with database errors.\n\nSymptoms: \"Database not found\" or migration errors in local dev.\n\nSolution: Apply migrations locally first:\n\n# Apply migrations to local D1\nwrangler d1 migrations apply my-app-db --local\n\n# Then run dev server\nwrangler dev\n\nIssue 13: User Data Updates Not Reflecting in UI (with TanStack Query)\n\nProblem: After updating user data (e.g., avatar, name), changes don't appear in useSession() despite calling queryClient.invalidateQueries().\n\nSymptoms: 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.\n\nRoot 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.\n\nSolution: Manually notify the nanostore after updating user data:\n\n// Update user data\nconst { data, error } = await authClient.updateUser({\n  image: newAvatarUrl,\n  name: newName\n})\n\nif (!error) {\n  // Manually invalidate better-auth session state\n  authClient.$store.notify('$sessionSignal')\n\n  // Optional: Also invalidate React Query if using it for other data\n  queryClient.invalidateQueries({ queryKey: ['user-profile'] })\n}\n\n\nWhen to use:\n\nUsing better-auth + TanStack Query together\nUpdating user profile fields (name, image, email)\nAny operation that modifies session user data client-side\n\nAlternative: Call refetch() from useSession(), but $store.notify() is more direct:\n\nconst { data: session, refetch } = authClient.useSession()\n// After update\nawait refetch()\n\n\nNote: $store is an undocumented internal API. This pattern is production-validated but may change in future better-auth versions.\n\nSource: Community-discovered pattern, production use verified\n\nIssue 14: apiKey Table Schema Mismatch with D1\n\nProblem: better-auth CLI (npx @better-auth/cli generate) fails with \"Failed to initialize database adapter\" when using D1.\n\nSymptoms: CLI cannot connect to D1 to introspect schema. Running migrations through CLI doesn't work.\n\nRoot Cause: The CLI expects a direct SQLite connection, but D1 requires Cloudflare's binding API.\n\nSolution: Skip the CLI and create migrations manually using the documented apiKey schema:\n\nCREATE TABLE api_key (\n  id TEXT PRIMARY KEY NOT NULL,\n  user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,\n  name TEXT,\n  start TEXT,\n  prefix TEXT,\n  key TEXT NOT NULL,\n  enabled INTEGER DEFAULT 1,\n  rate_limit_enabled INTEGER,\n  rate_limit_time_window INTEGER,\n  rate_limit_max INTEGER,\n  request_count INTEGER DEFAULT 0,\n  last_request INTEGER,\n  remaining INTEGER,\n  refill_interval INTEGER,\n  refill_amount INTEGER,\n  last_refill_at INTEGER,\n  expires_at INTEGER,\n  permissions TEXT,\n  metadata TEXT,\n  created_at INTEGER NOT NULL,\n  updated_at INTEGER NOT NULL\n);\n\n\nKey Points:\n\nThe table has exactly 21 columns (as of better-auth v1.4+)\nColumn names use snake_case (e.g., rate_limit_time_window, not rateLimitTimeWindow)\nD1 doesn't support ALTER TABLE DROP COLUMN - if schema drifts, use fresh migration pattern (drop and recreate tables)\nIn Drizzle adapter config, use apikey (lowercase) as the table name mapping\n\nFresh Migration Pattern for D1:\n\n-- Drop in reverse dependency order\nDROP TABLE IF EXISTS api_key;\nDROP TABLE IF EXISTS session;\n-- ... other tables\n\n-- Recreate with clean schema\nCREATE TABLE api_key (...);\n\n\nSource: Production debugging with D1 + better-auth apiKey plugin\n\nIssue 15: Admin Plugin Requires DB Role (Dual-Auth)\n\nProblem: Admin plugin methods like listUsers fail with \"You are not allowed to list users\" even though your middleware passes.\n\nSymptoms: Custom requireAdmin middleware (checking ADMIN_EMAILS env var) passes, but auth.api.listUsers() returns 403.\n\nRoot Cause: better-auth admin plugin has two authorization layers:\n\nYour middleware - Custom check (e.g., ADMIN_EMAILS)\nbetter-auth internal - Checks user.role === 'admin' in database\n\nBoth must pass for admin plugin methods to work.\n\nSolution: Set user role to 'admin' in the database:\n\n-- Fix for existing users\nUPDATE user SET role = 'admin' WHERE email = 'admin@example.com';\n\n\nOr use the admin UI/API to set roles after initial setup.\n\nWhy: The admin plugin's listUsers, banUser, impersonateUser, etc. all check user.role in the database, not your custom middleware logic.\n\nSource: Production debugging - misleading error message led to root cause discovery via wrangler tail\n\nIssue 16: Organization/Team updated_at Must Be Nullable\n\nProblem: Organization creation fails with SQL constraint error even though API returns \"slug already exists\".\n\nSymptoms:\n\nError message says \"An organization with this slug already exists\"\nDatabase table is actually empty\nwrangler tail shows: Failed query: insert into \"organization\" ... values (?, ?, ?, null, null, ?, null)\n\nRoot Cause: better-auth inserts null for updated_at on creation (only sets it on updates). If your schema has NOT NULL constraint, insert fails.\n\nSolution: Make updated_at nullable in both schema and migrations:\n\n// Drizzle schema - CORRECT\nexport const organization = sqliteTable('organization', {\n  // ...\n  updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()\n});\n\nexport const team = sqliteTable('team', {\n  // ...\n  updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()\n});\n\n-- Migration - CORRECT\nCREATE TABLE organization (\n  -- ...\n  updated_at INTEGER  -- No NOT NULL\n);\n\n\nApplies to: organization and team tables (possibly other plugin tables)\n\nSource: Production debugging - wrangler tail revealed actual SQL error behind misleading \"slug exists\" message\n\nIssue 17: API Response Double-Nesting (listMembers, etc.)\n\nProblem: Custom API endpoints return double-nested data like { members: { members: [...], total: N } }.\n\nSymptoms: UI shows \"undefined\" for counts, empty lists despite data existing.\n\nRoot Cause: better-auth methods like listMembers return { members: [...], total: N }. Wrapping with c.json({ members: result }) creates double nesting.\n\nSolution: Extract the array from better-auth response:\n\n// ❌ WRONG - Double nesting\nconst result = await auth.api.listMembers({ ... });\nreturn c.json({ members: result });\n// Returns: { members: { members: [...], total: N } }\n\n// ✅ CORRECT - Extract array\nconst result = await auth.api.listMembers({ ... });\nconst members = result?.members || [];\nreturn c.json({ members });\n// Returns: { members: [...] }\n\n\nAffected methods (return objects, not arrays):\n\nlistMembers → { members: [...], total: N }\nlistUsers → { users: [...], total: N, limit: N }\nlistOrganizations → { organizations: [...] } (check structure)\nlistInvitations → { invitations: [...] }\n\nPattern: Always check better-auth method return types before wrapping in your API response.\n\nSource: Production debugging - UI showed \"undefined\" count, API inspection revealed nesting issue\n\nIssue 18: Expo Client fromJSONSchema Crash (v1.4.16)\n\nProblem: Importing expoClient from @better-auth/expo/client crashes with TypeError: Cannot read property 'fromJSONSchema' of undefined on v1.4.16.\n\nSymptoms: Runtime crash immediately when importing expoClient in React Native/Expo apps.\n\nRoot Cause: Regression introduced after PR #6933 (cookie-based OAuth state fix for Expo). One of 3 commits after f4a9f15 broke the build.\n\nSolution:\n\nTemporary: Use continuous build at commit f4a9f15 (pre-regression)\nPermanent: Wait for fix (issue #7491 open as of 2026-01-20)\n// Crashes on v1.4.16\nimport { expoClient } from '@better-auth/expo/client'\n\n// Workaround: Use continuous build at f4a9f15\n// Or wait for fix in next release\n\n\nSource: GitHub Issue #7491\n\nIssue 19: additionalFields string[] Returns Stringified JSON\n\nProblem: After v1.4.12, additionalFields with type: 'string[]' return stringified arrays ('[\"a\",\"b\"]') instead of native arrays when querying via Drizzle directly.\n\nSymptoms: user.notificationTokens is a string, not an array. Code expecting arrays breaks.\n\nRoot 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.\n\nSolution:\n\nUse better-auth internalAdapter instead of querying Drizzle directly (has transformer)\nChange Drizzle schema to .jsonb() for string[] fields\nManually parse JSON strings until fixed\n// Config\nadditionalFields: {\n  notificationTokens: {\n    type: 'string[]',\n    required: true,\n    input: true,\n  },\n}\n\n// Create user\nnotificationTokens: ['token1', 'token2']\n\n// Result in DB (when querying via Drizzle directly)\n// '[\"token1\",\"token2\"]' (string, not array)\n\n\nSource: GitHub Issue #7440\n\nIssue 20: additionalFields \"returned\" Property Blocks Input\n\nProblem: Setting returned: false on additionalFields prevents field from being saved via API, even with input: true.\n\nSymptoms: Field never saved to database when creating/updating via API endpoints.\n\nRoot 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.\n\nSolution:\n\nDon't use returned: false if you need API write access\nWrite via server-side methods (auth.api.*) instead\n// Organization plugin config\nadditionalFields: {\n  secretField: {\n    type: 'string',\n    required: true,\n    input: true,      // Should allow API writes\n    returned: false,  // Should only block reads, but blocks writes too\n  },\n}\n\n// API request to create organization\n// secretField is never saved to database\n\n\nSource: GitHub Issue #7489\n\nIssue 21: freshAge Based on Creation Time, Not Activity\n\nProblem: session.freshAge checks time-since-creation, NOT recent activity. Active sessions become \"not fresh\" after freshAge elapses, even if used constantly.\n\nSymptoms: \"Fresh session required\" endpoints reject valid active sessions.\n\nWhy 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.\n\nSolution:\n\nSet updateAge <= freshAge to ensure freshness is updated before expiry\nAvoid \"fresh session required\" gating for long-lived sessions\nAccept as design: freshAge is strictly time-since-creation (maintainer confirmed)\n// Config\nsession: {\n  expiresIn: 60 * 60 * 24 * 7,    // 7 days\n  freshAge: 60 * 60 * 24,          // 24 hours\n  updateAge: 60 * 60 * 24 * 3,     // 3 days (> freshAge!) ⚠️ PROBLEM\n\n  // CORRECT - updateAge <= freshAge\n  updateAge: 60 * 60 * 12,         // 12 hours (< freshAge)\n}\n\n// Timeline with bad config:\n// T+0h: User signs in (createdAt = now)\n// T+12h: User makes requests (session active, still fresh)\n// T+25h: User makes request (session active, BUT NOT FRESH - freshAge elapsed)\n// Result: \"Fresh session required\" endpoints reject active session\n\n\nSource: GitHub Issue #7472\n\nIssue 22: OAuth Token Endpoints Return Wrapped JSON\n\nProblem: 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.\n\nSymptoms: OAuth clients fail with Bearer undefined or invalid_token.\n\nRoot Cause: The endpoint pipeline returns { response, headers, status } for internal use, which gets serialized directly for HTTP requests. This breaks OAuth/OIDC spec requirements.\n\nSolution:\n\nTemporary: Manually unwrap .response field on client\nPermanent: Wait for fix (issue #7355 open, accepting contributions)\n// Expected (spec-compliant)\n{ \"access_token\": \"...\", \"token_type\": \"bearer\", \"expires_in\": 3600 }\n\n// Actual (wrapped)\n{ \"response\": { \"access_token\": \"...\", \"token_type\": \"bearer\", \"expires_in\": 3600 } }\n\n// Result: OAuth clients fail to parse, send `Bearer undefined`\n\n\nSource: GitHub Issue #7355\n\nMigration Guides\nFrom Clerk\n\nKey differences:\n\nClerk: Third-party service → better-auth: Self-hosted\nClerk: Proprietary → better-auth: Open source\nClerk: Monthly cost → better-auth: Free\n\nMigration steps:\n\nExport user data from Clerk (CSV or API)\nImport into better-auth database:\n// migration script\nconst clerkUsers = await fetchClerkUsers();\n\nfor (const clerkUser of clerkUsers) {\n  await db.insert(user).values({\n    id: clerkUser.id,\n    email: clerkUser.email,\n    emailVerified: clerkUser.email_verified,\n    name: clerkUser.first_name + \" \" + clerkUser.last_name,\n    image: clerkUser.profile_image_url,\n  });\n}\n\nReplace Clerk SDK with better-auth client:\n// Before (Clerk)\nimport { useUser } from \"@clerk/nextjs\";\nconst { user } = useUser();\n\n// After (better-auth)\nimport { authClient } from \"@/lib/auth-client\";\nconst { data: session } = authClient.useSession();\nconst user = session?.user;\n\nUpdate middleware for session verification\nConfigure social providers (same OAuth apps, different config)\nFrom Auth.js (NextAuth)\n\nKey differences:\n\nAuth.js: Limited features → better-auth: Comprehensive (2FA, orgs, etc.)\nAuth.js: Callbacks-heavy → better-auth: Plugin-based\nAuth.js: Session handling varies → better-auth: Consistent\n\nMigration steps:\n\nDatabase schema: Auth.js and better-auth use similar schemas, but column names differ\nReplace configuration:\n// Before (Auth.js)\nimport NextAuth from \"next-auth\";\nimport GoogleProvider from \"next-auth/providers/google\";\n\nexport default NextAuth({\n  providers: [GoogleProvider({ /* ... */ })],\n});\n\n// After (better-auth)\nimport { betterAuth } from \"better-auth\";\n\nexport const auth = betterAuth({\n  socialProviders: {\n    google: { /* ... */ },\n  },\n});\n\nUpdate client hooks:\n// Before\nimport { useSession } from \"next-auth/react\";\n\n// After\nimport { authClient } from \"@/lib/auth-client\";\nconst { data: session } = authClient.useSession();\n\nAdditional Resources\nOfficial Documentation\nHomepage: https://better-auth.com\nIntroduction: https://www.better-auth.com/docs/introduction\nInstallation: https://www.better-auth.com/docs/installation\nBasic Usage: https://www.better-auth.com/docs/basic-usage\nCore Concepts\nSession Management: https://www.better-auth.com/docs/concepts/session-management\nUsers & Accounts: https://www.better-auth.com/docs/concepts/users-accounts\nClient SDK: https://www.better-auth.com/docs/concepts/client\nPlugins System: https://www.better-auth.com/docs/concepts/plugins\nAuthentication Methods\nEmail & Password: https://www.better-auth.com/docs/authentication/email-password\nOAuth Providers: https://www.better-auth.com/docs/concepts/oauth\nPlugin Documentation\n\nCore Plugins:\n\n2FA (Two-Factor): https://www.better-auth.com/docs/plugins/2fa\nOrganization: https://www.better-auth.com/docs/plugins/organization\nAdmin: https://www.better-auth.com/docs/plugins/admin\nMulti-Session: https://www.better-auth.com/docs/plugins/multi-session\nAPI Key: https://www.better-auth.com/docs/plugins/api-key\nGeneric OAuth: https://www.better-auth.com/docs/plugins/generic-oauth\n\nPasswordless Plugins:\n\nPasskey: https://www.better-auth.com/docs/plugins/passkey\nMagic Link: https://www.better-auth.com/docs/plugins/magic-link\nEmail OTP: https://www.better-auth.com/docs/plugins/email-otp\nPhone Number: https://www.better-auth.com/docs/plugins/phone-number\nAnonymous: https://www.better-auth.com/docs/plugins/anonymous\n\nAdvanced Plugins:\n\nUsername: https://www.better-auth.com/docs/plugins/username\nJWT: https://www.better-auth.com/docs/plugins/jwt\nOpenAPI: https://www.better-auth.com/docs/plugins/open-api\nOIDC Provider: https://www.better-auth.com/docs/plugins/oidc-provider\nSSO: https://www.better-auth.com/docs/plugins/sso\nStripe: https://www.better-auth.com/docs/plugins/stripe\nMCP: https://www.better-auth.com/docs/plugins/mcp\nFramework Integrations\nTanStack Start: https://www.better-auth.com/docs/integrations/tanstack\nExpo (React Native): https://www.better-auth.com/docs/integrations/expo\nCommunity & Support\nGitHub: https://github.com/better-auth/better-auth (22.4k ⭐)\nExamples: https://github.com/better-auth/better-auth/tree/main/examples\nDiscord: https://discord.gg/better-auth\nChangelog: https://github.com/better-auth/better-auth/releases\nRelated Documentation\nDrizzle ORM: https://orm.drizzle.team/docs/get-started-sqlite\nKysely: https://kysely.dev/\nProduction Examples\n\nVerified working D1 repositories (all use Drizzle or Kysely):\n\nzpg6/better-auth-cloudflare - Drizzle + D1 (includes CLI)\nzwily/example-react-router-cloudflare-d1-drizzle-better-auth - Drizzle + D1\nfoxlau/react-router-v7-better-auth - Drizzle + D1\nmatthewlynch/better-auth-react-router-cloudflare-d1 - Kysely + D1\n\nNone use a direct d1Adapter - all require Drizzle/Kysely.\n\nVersion Compatibility\n\nTested with:\n\nbetter-auth@1.4.10\ndrizzle-orm@0.45.1\ndrizzle-kit@0.31.8\nkysely@0.28.9\nkysely-d1@0.4.0\n@cloudflare/workers-types@latest\nhono@4.11.3\nNode.js 18+, Bun 1.0+\n\nBreaking changes:\n\nv1.4.6: allowImpersonatingAdmins defaults to false\nv1.4.0: ESM-only (no CommonJS)\nv1.3.0: Multi-team table structure change\n\nCheck changelog: https://github.com/better-auth/better-auth/releases\n\nCommunity Resources\n\nCloudflare-specific guides:\n\nzpg6/better-auth-cloudflare - Drizzle + D1 reference\nHono + better-auth on Cloudflare - Official Hono example\nReact Router + Cloudflare D1 - React Router v7 guide\nSvelteKit + Cloudflare D1 - SvelteKit guide\n\nToken Efficiency:\n\nWithout skill: ~35,000 tokens (D1 adapter errors, 15+ plugins, rate limiting, session caching, database hooks, mobile integration)\nWith skill: ~8,000 tokens (focused on errors + patterns + all plugins + API reference)\nSavings: ~77% (~27,000 tokens)\n\nErrors prevented: 22 documented issues with exact solutions Key value: D1 adapter requirement, nodejs_compat flag, OAuth 2.1 Provider, Bearer/OneTap/SCIM/Anonymous plugins, rate limiting, session caching, database hooks, Expo integration, 80+ endpoint reference, additionalFields bugs, freshAge behavior, OAuth token wrapping\n\nLast verified: 2026-01-21 | Skill version: 5.1.0 | Changes: Added 5 new issues from post-training-cutoff research (Expo fromJSONSchema crash, additionalFields string[] bug, additionalFields returned property bug, freshAge not activity-based, OAuth token wrapping). Expanded Issue #3 with Kysely CamelCasePlugin join parsing failure. Expanded Issue #5 with Hono CORS pattern. Added Cloudflare Workers DB binding constraints note. Added TanStack Start session nullability pattern. Updated to v1.4.16."
  },
  "trust": {
    "sourceLabel": "tencent",
    "provenanceUrl": "https://clawhub.ai/Veeramanikandanr48/better-auth",
    "publisherUrl": "https://clawhub.ai/Veeramanikandanr48/better-auth",
    "owner": "Veeramanikandanr48",
    "version": "0.1.0",
    "license": null,
    "verificationStatus": "Indexed source record"
  },
  "links": {
    "detailUrl": "https://openagent3.xyz/skills/better-auth",
    "downloadUrl": "https://openagent3.xyz/downloads/better-auth",
    "agentUrl": "https://openagent3.xyz/skills/better-auth/agent",
    "manifestUrl": "https://openagent3.xyz/skills/better-auth/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/better-auth/agent.md"
  }
}