better-auth by jezweb/claude-skills
npx skills add https://github.com/jezweb/claude-skills --skill better-auth包 : better-auth@1.4.16 (2026年1月21日) 破坏性变更 : 仅支持ESM (v1.4.0),默认禁止管理员模拟 (v1.4.6),多团队表变更 (v1.3),D1 需要 Drizzle/Kysely (无直接适配器)
better-auth 没有 d1Adapter()。您必须使用:
drizzleAdapter(db, { provider: "sqlite" })new Kysely({ dialect: new D1Dialect({ database: env.DB }) })详情请参见下面的问题 #1。
主要特性:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
backgroundTasks配置 - 延迟操作以获得更好性能disableRedirect选项管理员插件更新:
安全修复:
主要特性:
主要特性:
@better-auth/sso 包)teamId,需要新的 teamMembers 表如果您更喜欢 Kysely 而不是 Drizzle:
文件 : src/auth.ts
import { betterAuth } from "better-auth";
import { Kysely, CamelCasePlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
type Env = {
DB: D1Database;
BETTER_AUTH_SECRET: string;
// ... 其他环境变量
};
export function createAuth(env: Env) {
return betterAuth({
secret: env.BETTER_AUTH_SECRET,
// 使用 D1Dialect 的 Kysely
database: {
db: new Kysely({
dialect: new D1Dialect({
database: env.DB,
}),
plugins: [
// 关键:如果使用 snake_case 的 Drizzle 架构,则必须使用
new CamelCasePlugin(),
],
}),
type: "sqlite",
},
emailAndPassword: {
enabled: true,
},
// ... 其他配置
});
}
为什么需要 CamelCasePlugin?
如果您的 Drizzle 架构使用 snake_case 列名 (例如 email_verified),但 better-auth 期望 camelCase (例如 emailVerified),CamelCasePlugin 会自动在两者之间进行转换。
⚠️ Cloudflare Workers 注意 : D1 数据库绑定仅在请求处理程序内部可用 (即 fetch() 函数)。您不能在请求上下文之外初始化 better-auth。请使用工厂函数模式:
// ❌ 错误 - DB 绑定在请求外部不可用
const db = drizzle(env.DB, { schema }) // env.DB 在这里不存在
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite" }) })
// ✅ 正确 - 为每个请求创建 auth 实例
export default {
fetch(request, env, ctx) {
const db = drizzle(env.DB, { schema })
const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite" }) })
return auth.handler(request)
}
}
社区验证 : 多个生产实现确认了此模式 (Medium, AnswerOverflow, 官方 Hono 示例)。
⚠️ 关键 : TanStack Start 需要 reactStartCookies 插件来正确处理 Cookie 设置。
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { reactStartCookies } from "better-auth/react-start";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
plugins: [
twoFactor(),
organization(),
reactStartCookies(), // ⚠️ 必须是最后一个插件
],
});
为什么需要它 : TanStack Start 使用特殊的 Cookie 处理系统。没有此插件,像 signInEmail() 和 signUpEmail() 这样的认证函数将无法正确设置 Cookie,导致认证失败。
重要 : reactStartCookies 插件必须是数组中的最后一个插件。
会话可空性模式 : 在 TanStack Start 中使用 useSession() 时,会话对象始终存在,但未登录时 session.user 和 session.session 为 null:
const { data: session } = authClient.useSession()
// 当未登录时:
console.log(session) // { user: null, session: null }
console.log(!!session) // true (意外!)
// 正确检查:
if (session?.user) {
// 用户已登录
}
始终检查session?.user 或 session?.session,而不仅仅是 session。这是预期的行为 (会话对象容器始终存在)。
API 路由设置 (/src/routes/api/auth/$.ts):
import { auth } from '@/lib/auth'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
})
Better Auth 为高级认证功能提供插件:
| 插件 | 导入 | 描述 | 文档 |
|---|---|---|---|
| OAuth 2.1 提供者 | better-auth/plugins | 使用 PKCE、JWT 令牌、同意流程构建 OAuth 2.1 提供者 (取代 MCP 和 OIDC 插件) | 📚 |
| SSO | better-auth/plugins | 支持 OIDC、OAuth2 和 SAML 2.0 的企业单点登录 | 📚 |
| Stripe | better-auth/plugins | 具有灵活生命周期处理的支付和订阅管理 | 📚 |
| MCP | better-auth/plugins | ⚠️ 已弃用 - 请改用 OAuth 2.1 提供者 | 📚 |
| Expo | better-auth/expo | React Native/Expo,支持 webBrowserOptions 和最后登录方法跟踪 | 📚 |
为 MCP 服务器、第三方应用或 API 访问构建您自己的 OAuth 提供者:
import { betterAuth } from "better-auth";
import { oauthProvider } from "better-auth/plugins";
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
jwt(), // 令牌签名必需
oauthProvider({
// 令牌过期时间 (秒)
accessTokenExpiresIn: 3600, // 1 小时
refreshTokenExpiresIn: 2592000, // 30 天
authorizationCodeExpiresIn: 600, // 10 分钟
}),
],
});
关键特性:
authorization_code, refresh_token, client_credentials/oauth2/userinfo 支持基于范围的声明必需的知名端点:
// app/api/.well-known/oauth-authorization-server/route.ts
export async function GET() {
return Response.json({
issuer: process.env.BETTER_AUTH_URL,
authorization_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/authorize`,
token_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/token`,
// ... 其他元数据
});
}
创建 OAuth 客户端:
const client = await auth.api.createOAuthClient({
body: {
name: "我的 MCP 服务器",
redirectURLs: ["https://claude.ai/callback"],
type: "public", // 或 "confidential"
},
});
// 返回: { clientId, clientSecret (如果是 confidential) }
⚠️ 注意 : 此插件正在积极开发中,可能尚不适合生产使用。
用于仅限 API 的认证 (移动应用、CLI 工具、第三方集成):
import { bearer } from "better-auth/plugins";
import { bearerClient } from "better-auth/client/plugins";
// 服务器
export const auth = betterAuth({
plugins: [bearer()],
});
// 客户端 - 登录后存储令牌
const { token } = await authClient.signIn.email({ email, password });
localStorage.setItem("auth_token", token);
// 客户端 - 配置 fetch 以包含令牌
const authClient = createAuthClient({
plugins: [bearerClient()],
fetchOptions: {
auth: { type: "Bearer", token: () => localStorage.getItem("auth_token") },
},
});
为已登录 Google 的用户提供无摩擦的单点登录:
import { oneTap } from "better-auth/plugins";
import { oneTapClient } from "better-auth/client/plugins";
// 服务器
export const auth = betterAuth({
plugins: [oneTap()],
});
// 客户端
authClient.oneTap({
onSuccess: (session) => {
window.location.href = "/dashboard";
},
});
要求 : 在 Google Cloud Console 中配置授权的 JavaScript 来源。
无需电子邮件/密码的访客访问:
import { anonymous } from "better-auth/plugins";
// 服务器
export const auth = betterAuth({
plugins: [
anonymous({
emailDomainName: "anon.example.com", // temp@{id}.anon.example.com
onLinkAccount: async ({ anonymousUser, newUser }) => {
// 将匿名用户数据迁移到链接账户
await migrateUserData(anonymousUser.id, newUser.id);
},
}),
],
});
// 客户端
await authClient.signIn.anonymous();
// 稍后:用户可以通过 signIn.social/email 链接到真实账户
添加内置列表中未包含的自定义 OAuth 提供者:
import { genericOAuth } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
{
providerId: "linear",
clientId: env.LINEAR_CLIENT_ID,
clientSecret: env.LINEAR_CLIENT_SECRET,
discoveryUrl: "https://linear.app/.well-known/openid-configuration",
scopes: ["openid", "email", "profile"],
pkce: true, // 推荐
},
],
}),
],
});
回调 URL 模式 : {baseURL}/api/auth/oauth2/callback/{providerId}
内置速率限制,支持自定义规则:
export const auth = betterAuth({
rateLimit: {
window: 60, // 秒 (默认: 60)
max: 100, // 每个窗口的请求数 (默认: 100)
// 敏感端点的自定义规则
customRules: {
"/sign-in/email": { window: 10, max: 3 },
"/two-factor/*": { window: 10, max: 3 },
"/forget-password": { window: 60, max: 5 },
},
// 分布式系统使用 Redis/KV
storage: "secondary-storage", // 或 "database"
},
// 速率限制的辅助存储
secondaryStorage: {
get: async (key) => env.KV.get(key),
set: async (key, value, ttl) => env.KV.put(key, value, { expirationTtl: ttl }),
delete: async (key) => env.KV.delete(key),
},
});
注意 : 通过 auth.api.* 进行的服务器端调用会绕过速率限制。
将会话完全存储在签名的 Cookie 中,无需数据库存储:
export const auth = betterAuth({
session: {
// 无状态:无数据库存储,会话仅存在于 Cookie 中
storage: undefined, // 或完全省略
// Cookie 配置
cookieCache: {
enabled: true,
maxAge: 60 * 60 * 24 * 7, // 7 天
encoding: "jwt", // 无状态使用 JWT (不是 "compact")
},
// 会话过期时间
expiresIn: 60 * 60 * 24 * 7, // 7 天
},
});
使用时机:
| 存储类型 | 使用场景 | 权衡 |
|---|---|---|
| 无状态 (仅 Cookie) | 读密集型应用,边缘/无服务器,无需撤销 | 无法撤销会话,有效载荷大小有限 |
| D1 数据库 | 完整的会话管理,审计跟踪,可撤销 | 最终一致性问题 |
| KV 存储 | 强一致性,高读取性能 | 额外的绑定设置 |
关键点:
encoding: "jwt" 以实现互操作性,"jwe" 用于加密BETTER_AUTH_SECRET自动轮换 JWT 签名密钥以增强安全性:
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
jwt({
// 密钥轮换 (可选,企业级安全)
keyRotation: {
enabled: true,
rotationInterval: 60 * 60 * 24 * 30, // 每 30 天轮换一次
keepPreviousKeys: 3, // 保留 3 个旧密钥用于验证
},
// 自定义签名算法 (默认: HS256)
algorithm: "RS256", // 需要非对称密钥
// JWKS 端点 (自动生成于 /api/auth/jwks)
exposeJWKS: true,
}),
],
});
关键点:
/api/auth/jwks常见 OAuth 提供者及其获取用户数据所需的范围:
| 提供者 | 范围 | 返回内容 |
|---|---|---|
openid | 仅用户 ID | |
email | 电子邮件地址,email_verified | |
profile | 姓名,头像 (picture),语言环境 | |
| GitHub | user:email | 电子邮件地址 (可能为私有) |
read:user | 姓名,头像,个人资料 URL,简介 | |
| Microsoft | openid | 仅用户 ID |
email | 电子邮件地址 | |
profile | 姓名,语言环境 | |
User.Read | Graph API 的完整个人资料 | |
| Discord | identify | 用户名,头像,识别码 |
email | 电子邮件地址 | |
| Apple | name | 名字/姓氏 (仅首次认证) |
email | 电子邮件或中继地址 | |
| Patreon | identity | 用户 ID,姓名 |
identity[email] | 电子邮件地址 | |
| Vercel | (自动) | 电子邮件,姓名,头像 |
配置示例:
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"], // 所有用户数据
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["user:email", "read:user"], // 电子邮件 + 完整个人资料
},
microsoft: {
clientId: env.MS_CLIENT_ID,
clientSecret: env.MS_CLIENT_SECRET,
scope: ["openid", "email", "profile", "User.Read"],
},
}
会话 Cookie 的三种编码策略:
| 策略 | 格式 | 使用场景 |
|---|---|---|
| Compact (默认) | Base64url + HMAC-SHA256 | 最小,最快 |
| JWT | 标准 JWT | 可互操作 |
| JWE | A256CBC-HS512 加密 | 最安全 |
export const auth = betterAuth({
session: {
cookieCache: {
enabled: true,
maxAge: 300, // 5 分钟
encoding: "compact", // 或 "jwt" 或 "jwe"
},
freshAge: 60 * 60 * 24, // 1 天 - 需要新鲜会话的操作
},
});
新鲜会话 : 某些敏感操作需要最近创建的会话。配置 freshAge 来控制此窗口。
socialProviders: {
// Patreon - 创作者经济
patreon: {
clientId: env.PATREON_CLIENT_ID,
clientSecret: env.PATREON_CLIENT_SECRET,
scope: ["identity", "identity[email]"],
},
// Kick - 流媒体平台 (支持刷新令牌)
kick: {
clientId: env.KICK_CLIENT_ID,
clientSecret: env.KICK_CLIENT_SECRET,
},
// Vercel - 开发者平台
vercel: {
clientId: env.VERCEL_CLIENT_ID,
clientSecret: env.VERCEL_CLIENT_SECRET,
},
}
⚠️ 关键 : Cloudflare Workers 需要 AsyncLocalStorage 支持:
# wrangler.toml
compatibility_flags = ["nodejs_compat"]
# 或对于旧版 Workers:
# compatibility_flags = ["nodejs_als"]
没有此标志,better-auth 将因上下文相关错误而失败。
在数据库操作期间执行自定义逻辑:
export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async (user, ctx) => {
// 创建前验证或修改
if (user.email?.endsWith("@blocked.com")) {
throw new APIError("BAD_REQUEST", { message: "不允许的电子邮件域名" });
}
return { data: { ...user, role: "member" } };
},
after: async (user, ctx) => {
// 发送欢迎邮件,创建相关记录等
await sendWelcomeEmail(user.email);
await createDefaultWorkspace(user.id);
},
},
},
session: {
create: {
after: async (session, ctx) => {
// 审计日志
await auditLog.create({ action: "session_created", userId: session.userId });
},
},
},
},
});
可用钩子 : user, session, account, verification 表的 create, update。
完整的移动集成模式:
// 使用安全存储的客户端设置
import { expoClient } from "@better-auth/expo";
import * as SecureStore from "expo-secure-store";
const authClient = createAuthClient({
baseURL: "https://api.example.com",
plugins: [expoClient({ storage: SecureStore })],
});
// 使用深度链接的 OAuth
await authClient.signIn.social({
provider: "google",
callbackURL: "myapp://auth/callback", // 深度链接
});
// 或使用 ID 令牌验证 (无重定向)
await authClient.signIn.social({
provider: "google",
idToken: {
token: googleIdToken,
nonce: generatedNonce,
},
});
// 认证请求
const cookie = await authClient.getCookie();
await fetch("https://api.example.com/data", {
headers: { Cookie: cookie },
credentials: "omit",
});
app.json 深度链接设置 :
{
"expo": {
"scheme": "myapp"
}
}
服务器 trustedOrigins (开发环境):
trustedOrigins: ["exp://**", "myapp://"]
当您调用 auth.handler() 时,better-auth 会自动在 /api/auth/* 处暴露 80 多个生产就绪的 REST 端点。每个端点也可以通过 auth.api.* 作为服务器端方法使用,以便进行编程式调用。
这个双层 API 系统意味着:
auth.api.* 方法时间节省 : 从头开始构建 = ~220 小时。使用 better-auth = ~4-8 小时。减少 97%。
使用 auth.handler() 时,所有端点都会自动暴露在 /api/auth/*。
| 端点 | 方法 | 描述 |
|---|---|---|
/sign-up/email | POST | 使用电子邮件/密码注册 |
/sign-in/email | POST | 使用电子邮件/密码认证 |
/sign-out | POST | 用户登出 |
/change-password | POST | 更新密码 (需要当前密码) |
/forget-password | POST | 发起密码重置流程 |
/reset-password | POST | 使用令牌完成密码重置 |
/send-verification-email | POST | 发送电子邮件验证链接 |
/verify-email | GET | 使用令牌验证电子邮件 (?token=<token>) |
/get-session | GET | 检索当前会话 |
/list-sessions | GET | 获取所有活跃用户会话 |
/revoke-session | POST | 结束特定会话 |
/revoke-other-sessions | POST | 结束除当前会话外的所有会话 |
/revoke-sessions | POST | 结束所有用户会话 |
/update-user | POST | 修改用户个人资料 (姓名,图片) |
/change-email | POST | 更新电子邮件地址 |
/set-password | POST | 为仅 OAuth 账户添加密码 |
/delete-user | POST | 删除用户账户 |
/list-accounts | GET | 获取链接的认证提供者 |
/link-social | POST | 将 OAuth 提供者连接到账户 |
/unlink-account | POST | 断开提供者连接 |
| 端点 | 方法 | 描述 |
|---|---|---|
/sign-in/social | POST | 发起 OAuth 流程 (在请求体中指定提供者) |
/callback/:provider | GET | OAuth 回调处理程序 (例如,/callback/google) |
/get-access-token | GET | 检索提供者访问令牌 |
OAuth 流程示例 :
// 客户端发起
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
// better-auth 处理重定向到 Google
// Google 重定向回 /api/auth/callback/google
// better-auth 自动创建会话
import { twoFactor } from "better-auth/plugins";
| 端点 | 方法 | 描述 |
|---|---|---|
/two-factor/enable | POST | 为用户启用 2FA |
/two-factor/disable | POST | 停用 2FA |
/two-factor/get-totp-uri | GET | 获取验证器应用的二维码 URI |
/two-factor/verify-totp | POST | 验证来自验证器的 TOTP 代码 |
/two-factor/send-otp | POST | 通过电子邮件发送 OTP |
/two-factor/verify-otp | POST | 验证电子邮件 OTP |
/two-factor/generate-backup-codes | POST | 创建恢复代码 |
/two-factor/verify-backup-code | POST | 使用备份代码登录 |
/two-factor/view-backup-codes | GET | 查看当前备份代码 |
import { organization } from "better-auth/plugins";
组织 (10 个端点):
| 端点 | 方法 | 描述 |
|---|---|---|
/organization/create | POST | 创建组织 |
/organization/list | GET | 列出用户所属的组织 |
/organization/get-full | GET | 获取完整的组织详情 |
/organization/update | PUT | 修改组织 |
/organization/delete | DELETE | 删除组织 |
/organization/check-slug | GET | 验证 slug 可用性 |
/organization/set-active | POST | 设置活跃的组织上下文 |
成员 (8 个端点):
| 端点 | 方法 | 描述 |
|---|---|---|
/organization/list-members | GET | 获取组织成员 |
/organization/add-member | POST | 直接添加成员 |
/organization/remove-member | DELETE | 移除成员 |
/organization/update-member-role | PUT | 更改成员角色 |
/organization/get-active-member | GET | 获取当前成员信息 |
/organization/leave | POST | 离开组织 |
邀请 (7 个端点):
| 端点 | 方法 | 描述 |
|---|---|---|
/organization/invite-member | POST | 发送邀请邮件 |
/organization/accept-invitation | POST | 接受邀请 |
/organization/reject-invitation | POST | 拒绝邀请 |
/organization/cancel-invitation | POST | 取消待处理的邀请 |
/organization/get-invitation | GET | 获取邀请详情 |
/organization/list-invitations | GET | 列出组织邀请 |
/organization/list-user-invitations | GET | 列出用户的待处理邀请 |
团队 (8 个端点):
| 端点 | 方法 | 描述 |
|---|---|---|
/organization/create-team | POST | 在组织内创建团队 |
/organization/list-teams | GET | 列出组织团队 |
/organization/update-team | PUT | 修改团队 |
/organization/remove-team | DELETE | 移除团队 |
/organization/set-active-team | POST | 设置活跃的团队上下文 |
/organization/list-team-members | GET | 列出团队成员 |
/organization/add-team-member | POST | 添加成员到团队 |
/organization/remove-team-member | DELETE | 从团队移除成员 |
权限与角色 (6 个端点):
| 端点 | 方法 | 描述 |
|---|---|---|
/organization/has-permission | POST | 检查用户是否具有权限 |
/organization/create-role | POST | 创建自定义角色 |
/organization/delete-role | DELETE | 删除自定义角色 |
/organization/list-roles | GET | 列出所有角色 |
/organization/get-role | GET | 获取角色详情 |
/organization/update-role | PUT | 修改角色权限 |
import { admin } from "better-auth/plugins";
// v1.4.10 配置选项
admin({
defaultRole: "user",
adminRoles: ["admin"],
adminUserIds: ["user_abc123"], // 始终授予特定用户管理员权限
impersonationSessionDuration: 3600, // 1 小时 (秒)
allowImpersonatingAdmins: false, // ⚠️ v1.4.6 中默认更改
defaultBanReason: "违反服务条款",
bannedUserMessage: "您的账户已被暂停",
})
| 端点 | 方法 | 描述 |
|---|---|---|
/admin/create-user | POST | 以管理员身份创建用户 |
/admin/list-users | GET | 列出所有用户 (带过滤器/分页) |
/admin/set-role | POST | 分配用户角色 |
/admin/set-user-password | POST | 更改用户密码 |
/admin/update-user | PUT | 修改用户详情 |
/admin/remove-user | DELETE | 删除用户账户 |
/admin/ban-user | POST | 封禁用户账户 (可选过期时间) |
/admin/unban-user | POST | 解封用户 |
/admin/list-user-sessions | GET | 获取用户的活跃会话 |
/admin/revoke-user-session | DELETE | 结束特定用户会话 |
/admin/revoke-user-sessions | DELETE | 结束所有用户会话 |
/admin/impersonate-user | POST | 开始模拟用户 |
/admin/stop-impersonating | POST | 结束模拟会话 |
⚠️ 破坏性变更 (v1.4.6) : allowImpersonatingAdmins 现在默认为 false。如果需要管理员模拟管理员,请显式设置为 true。
具有权限的自定义角色 (v1.4.10):
import { createAccessControl } from "better-auth/plugins/access";
// 定义资源和权限
const ac = createAccessControl({
user: ["create", "read", "update", "delete", "ban", "impersonate"],
project: ["create", "read", "update", "delete", "share"],
} as const);
// 创建自定义角色
const supportRole = ac.newRole({
user: ["read", "ban"], // 可以查看和封禁用户
project: ["read"], // 可以查看项目
});
const managerRole = ac.newRole({
user: ["read", "update"],
project: ["create", "read", "update", "delete"],
});
// 在插件中使用
admin({
ac,
roles: {
support: supportRole,
manager: managerRole,
},
})
通行密钥插件 (5
Package : better-auth@1.4.16 (Jan 21, 2026) Breaking Changes : ESM-only (v1.4.0), Admin impersonation prevention default (v1.4.6), Multi-team table changes (v1.3), D1 requires Drizzle/Kysely (no direct adapter)
better-auth DOES NOT have d1Adapter(). You MUST use:
drizzleAdapter(db, { provider: "sqlite" })new Kysely({ dialect: new D1Dialect({ database: env.DB }) })See Issue #1 below for details.
Major Features:
backgroundTasks config - Deferred actions for better performancedisableRedirect optionAdmin Plugin Updates:
Security Fixes:
📚 Docs : https://www.better-auth.com/changelogs
Major Features:
📚 Docs : https://www.better-auth.com/changelogs
Major Features:
@better-auth/sso package)teamId removed from member table, new teamMembers table required📚 Docs : https://www.better-auth.com/blog/1-3
If you prefer Kysely over Drizzle:
File : src/auth.ts
import { betterAuth } from "better-auth";
import { Kysely, CamelCasePlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
type Env = {
DB: D1Database;
BETTER_AUTH_SECRET: string;
// ... other env vars
};
export function createAuth(env: Env) {
return betterAuth({
secret: env.BETTER_AUTH_SECRET,
// Kysely with D1Dialect
database: {
db: new Kysely({
dialect: new D1Dialect({
database: env.DB,
}),
plugins: [
// CRITICAL: Required if using Drizzle schema with snake_case
new CamelCasePlugin(),
],
}),
type: "sqlite",
},
emailAndPassword: {
enabled: true,
},
// ... other config
});
}
Why CamelCasePlugin?
If your Drizzle schema uses snake_case column names (e.g., email_verified), but better-auth expects camelCase (e.g., emailVerified), the CamelCasePlugin automatically converts between the two.
⚠️ Cloudflare Workers Note : D1 database bindings are only available inside the request handler (the fetch() function). You cannot initialize better-auth outside the request context. Use a factory function pattern:
// ❌ WRONG - DB binding not available outside request
const db = drizzle(env.DB, { schema }) // env.DB doesn't exist here
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite" }) })
// ✅ CORRECT - Create auth instance per-request
export default {
fetch(request, env, ctx) {
const db = drizzle(env.DB, { schema })
const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite" }) })
return auth.handler(request)
}
}
Community Validation : Multiple production implementations confirm this pattern (Medium, AnswerOverflow, official Hono examples).
⚠️ CRITICAL : TanStack Start requires the reactStartCookies plugin to handle cookie setting properly.
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { reactStartCookies } from "better-auth/react-start";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
plugins: [
twoFactor(),
organization(),
reactStartCookies(), // ⚠️ MUST be LAST plugin
],
});
Why it's needed : TanStack Start uses a special cookie handling system. Without this plugin, auth functions like signInEmail() and signUpEmail() won't set cookies properly, causing authentication to fail.
Important : The reactStartCookies plugin must be the last plugin in the array.
Session Nullability Pattern : When using useSession() in TanStack Start, the session object always exists, but session.user and session.session are null when not logged in:
const { data: session } = authClient.useSession()
// When NOT logged in:
console.log(session) // { user: null, session: null }
console.log(!!session) // true (unexpected!)
// Correct check:
if (session?.user) {
// User is logged in
}
Always checksession?.user or session?.session, not just session. This is expected behavior (session object container always exists).
API Route Setup (/src/routes/api/auth/$.ts):
import { auth } from '@/lib/auth'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => auth.handler(request),
POST: ({ request }) => auth.handler(request),
},
},
})
📚 Official Docs : https://www.better-auth.com/docs/integrations/tanstack
Better Auth provides plugins for advanced authentication features:
| Plugin | Import | Description | Docs |
|---|---|---|---|
| OAuth 2.1 Provider | better-auth/plugins | Build OAuth 2.1 provider with PKCE, JWT tokens, consent flows (replaces MCP & OIDC plugins) | 📚 |
| SSO | better-auth/plugins | Enterprise Single Sign-On with OIDC, OAuth2, and SAML 2.0 support | 📚 |
| Stripe | better-auth/plugins | Payment and subscription management with flexible lifecycle handling | 📚 |
Build your own OAuth provider for MCP servers, third-party apps, or API access:
import { betterAuth } from "better-auth";
import { oauthProvider } from "better-auth/plugins";
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
jwt(), // Required for token signing
oauthProvider({
// Token expiration (seconds)
accessTokenExpiresIn: 3600, // 1 hour
refreshTokenExpiresIn: 2592000, // 30 days
authorizationCodeExpiresIn: 600, // 10 minutes
}),
],
});
Key Features:
authorization_code, refresh_token, client_credentials/oauth2/userinfo with scope-based claimsRequired Well-Known Endpoints:
// app/api/.well-known/oauth-authorization-server/route.ts
export async function GET() {
return Response.json({
issuer: process.env.BETTER_AUTH_URL,
authorization_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/authorize`,
token_endpoint: `${process.env.BETTER_AUTH_URL}/api/auth/oauth2/token`,
// ... other metadata
});
}
Create OAuth Client:
const client = await auth.api.createOAuthClient({
body: {
name: "My MCP Server",
redirectURLs: ["https://claude.ai/callback"],
type: "public", // or "confidential"
},
});
// Returns: { clientId, clientSecret (if confidential) }
📚 Full Docs : https://www.better-auth.com/docs/plugins/oauth-provider
⚠️ Note : This plugin is in active development and may not be suitable for production use yet.
| Plugin | Description | Docs |
|---|---|---|
| Bearer | API token auth (alternative to cookies for APIs) | 📚 |
| One Tap | Google One Tap frictionless sign-in | 📚 |
| SCIM | Enterprise user provisioning (SCIM 2.0) | 📚 |
| Anonymous | Guest user access without PII | 📚 |
| Username | Username-based sign-in (alternative to email) | 📚 |
| Generic OAuth | Custom OAuth providers with PKCE |
For API-only authentication (mobile apps, CLI tools, third-party integrations):
import { bearer } from "better-auth/plugins";
import { bearerClient } from "better-auth/client/plugins";
// Server
export const auth = betterAuth({
plugins: [bearer()],
});
// Client - Store token after sign-in
const { token } = await authClient.signIn.email({ email, password });
localStorage.setItem("auth_token", token);
// Client - Configure fetch to include token
const authClient = createAuthClient({
plugins: [bearerClient()],
fetchOptions: {
auth: { type: "Bearer", token: () => localStorage.getItem("auth_token") },
},
});
Frictionless single-tap sign-in for users already signed into Google:
import { oneTap } from "better-auth/plugins";
import { oneTapClient } from "better-auth/client/plugins";
// Server
export const auth = betterAuth({
plugins: [oneTap()],
});
// Client
authClient.oneTap({
onSuccess: (session) => {
window.location.href = "/dashboard";
},
});
Requirement : Configure authorized JavaScript origins in Google Cloud Console.
Guest access without requiring email/password:
import { anonymous } from "better-auth/plugins";
// Server
export const auth = betterAuth({
plugins: [
anonymous({
emailDomainName: "anon.example.com", // temp@{id}.anon.example.com
onLinkAccount: async ({ anonymousUser, newUser }) => {
// Migrate anonymous user data to linked account
await migrateUserData(anonymousUser.id, newUser.id);
},
}),
],
});
// Client
await authClient.signIn.anonymous();
// Later: user can link to real account via signIn.social/email
Add custom OAuth providers not in the built-in list:
import { genericOAuth } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
genericOAuth({
config: [
{
providerId: "linear",
clientId: env.LINEAR_CLIENT_ID,
clientSecret: env.LINEAR_CLIENT_SECRET,
discoveryUrl: "https://linear.app/.well-known/openid-configuration",
scopes: ["openid", "email", "profile"],
pkce: true, // Recommended
},
],
}),
],
});
Callback URL pattern : {baseURL}/api/auth/oauth2/callback/{providerId}
Built-in rate limiting with customizable rules:
export const auth = betterAuth({
rateLimit: {
window: 60, // seconds (default: 60)
max: 100, // requests per window (default: 100)
// Custom rules for sensitive endpoints
customRules: {
"/sign-in/email": { window: 10, max: 3 },
"/two-factor/*": { window: 10, max: 3 },
"/forget-password": { window: 60, max: 5 },
},
// Use Redis/KV for distributed systems
storage: "secondary-storage", // or "database"
},
// Secondary storage for rate limiting
secondaryStorage: {
get: async (key) => env.KV.get(key),
set: async (key, value, ttl) => env.KV.put(key, value, { expirationTtl: ttl }),
delete: async (key) => env.KV.delete(key),
},
});
Note : Server-side calls via auth.api.* bypass rate limiting.
Store sessions entirely in signed cookies without database storage:
export const auth = betterAuth({
session: {
// Stateless: No database storage, session lives in cookie only
storage: undefined, // or omit entirely
// Cookie configuration
cookieCache: {
enabled: true,
maxAge: 60 * 60 * 24 * 7, // 7 days
encoding: "jwt", // Use JWT for stateless (not "compact")
},
// Session expiration
expiresIn: 60 * 60 * 24 * 7, // 7 days
},
});
When to Use:
| Storage Type | Use Case | Tradeoffs |
|---|---|---|
| Stateless (cookie-only) | Read-heavy apps, edge/serverless, no revocation needed | Can't revoke sessions, limited payload size |
| D1 Database | Full session management, audit trails, revocation | Eventual consistency issues |
| KV Storage | Strong consistency, high read performance | Extra binding setup |
Key Points:
encoding: "jwt" for interoperability, "jwe" for encryptedBETTER_AUTH_SECRET across all instancesAutomatically rotate JWT signing keys for enhanced security:
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
jwt({
// Key rotation (optional, enterprise security)
keyRotation: {
enabled: true,
rotationInterval: 60 * 60 * 24 * 30, // Rotate every 30 days
keepPreviousKeys: 3, // Keep 3 old keys for validation
},
// Custom signing algorithm (default: HS256)
algorithm: "RS256", // Requires asymmetric keys
// JWKS endpoint (auto-generated at /api/auth/jwks)
exposeJWKS: true,
}),
],
});
Key Points:
/api/auth/jwks for external servicesCommon OAuth providers and the scopes needed for user data:
| Provider | Scope | Returns |
|---|---|---|
openid | User ID only | |
email | Email address, email_verified | |
profile | Name, avatar (picture), locale | |
| GitHub | user:email | Email address (may be private) |
read:user | Name, avatar, profile URL, bio |
Configuration Example:
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"], // All user data
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["user:email", "read:user"], // Email + full profile
},
microsoft: {
clientId: env.MS_CLIENT_ID,
clientSecret: env.MS_CLIENT_SECRET,
scope: ["openid", "email", "profile", "User.Read"],
},
}
Three encoding strategies for session cookies:
| Strategy | Format | Use Case |
|---|---|---|
| Compact (default) | Base64url + HMAC-SHA256 | Smallest, fastest |
| JWT | Standard JWT | Interoperable |
| JWE | A256CBC-HS512 encrypted | Most secure |
export const auth = betterAuth({
session: {
cookieCache: {
enabled: true,
maxAge: 300, // 5 minutes
encoding: "compact", // or "jwt" or "jwe"
},
freshAge: 60 * 60 * 24, // 1 day - operations requiring fresh session
},
});
Fresh sessions : Some sensitive operations require recently created sessions. Configure freshAge to control this window.
socialProviders: {
// Patreon - Creator economy
patreon: {
clientId: env.PATREON_CLIENT_ID,
clientSecret: env.PATREON_CLIENT_SECRET,
scope: ["identity", "identity[email]"],
},
// Kick - Streaming platform (with refresh tokens)
kick: {
clientId: env.KICK_CLIENT_ID,
clientSecret: env.KICK_CLIENT_SECRET,
},
// Vercel - Developer platform
vercel: {
clientId: env.VERCEL_CLIENT_ID,
clientSecret: env.VERCEL_CLIENT_SECRET,
},
}
⚠️ CRITICAL : Cloudflare Workers require AsyncLocalStorage support:
# wrangler.toml
compatibility_flags = ["nodejs_compat"]
# or for older Workers:
# compatibility_flags = ["nodejs_als"]
Without this flag, better-auth will fail with context-related errors.
Execute custom logic during database operations:
export const auth = betterAuth({
databaseHooks: {
user: {
create: {
before: async (user, ctx) => {
// Validate or modify before creation
if (user.email?.endsWith("@blocked.com")) {
throw new APIError("BAD_REQUEST", { message: "Email domain not allowed" });
}
return { data: { ...user, role: "member" } };
},
after: async (user, ctx) => {
// Send welcome email, create related records, etc.
await sendWelcomeEmail(user.email);
await createDefaultWorkspace(user.id);
},
},
},
session: {
create: {
after: async (session, ctx) => {
// Audit logging
await auditLog.create({ action: "session_created", userId: session.userId });
},
},
},
},
});
Available hooks : create, update for user, session, account, verification tables.
Complete mobile integration pattern:
// Client setup with secure storage
import { expoClient } from "@better-auth/expo";
import * as SecureStore from "expo-secure-store";
const authClient = createAuthClient({
baseURL: "https://api.example.com",
plugins: [expoClient({ storage: SecureStore })],
});
// OAuth with deep linking
await authClient.signIn.social({
provider: "google",
callbackURL: "myapp://auth/callback", // Deep link
});
// Or use ID token verification (no redirect)
await authClient.signIn.social({
provider: "google",
idToken: {
token: googleIdToken,
nonce: generatedNonce,
},
});
// Authenticated requests
const cookie = await authClient.getCookie();
await fetch("https://api.example.com/data", {
headers: { Cookie: cookie },
credentials: "omit",
});
app.json deep link setup :
{
"expo": {
"scheme": "myapp"
}
}
Server trustedOrigins (development):
trustedOrigins: ["exp://**", "myapp://"]
When you call auth.handler(), better-auth automatically exposes 80+ production-ready REST endpoints at /api/auth/*. Every endpoint is also available as a server-side method via auth.api.* for programmatic use.
This dual-layer API system means:
auth.api.* methodsTime savings : Building this from scratch = ~220 hours. With better-auth = ~4-8 hours. 97% reduction.
All endpoints are automatically exposed at /api/auth/* when using auth.handler().
| Endpoint | Method | Description |
|---|---|---|
/sign-up/email | POST | Register with email/password |
/sign-in/email | POST | Authenticate with email/password |
/sign-out | POST | Logout user |
/change-password | POST | Update password (requires current password) |
/forget-password | POST |
| Endpoint | Method | Description |
|---|---|---|
/sign-in/social | POST | Initiate OAuth flow (provider specified in body) |
/callback/:provider | GET | OAuth callback handler (e.g., /callback/google) |
/get-access-token | GET | Retrieve provider access token |
Example OAuth flow :
// Client initiates
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
// better-auth handles redirect to Google
// Google redirects back to /api/auth/callback/google
// better-auth creates session automatically
import { twoFactor } from "better-auth/plugins";
| Endpoint | Method | Description |
|---|---|---|
/two-factor/enable | POST | Activate 2FA for user |
/two-factor/disable | POST | Deactivate 2FA |
/two-factor/get-totp-uri | GET | Get QR code URI for authenticator app |
/two-factor/verify-totp | POST | Validate TOTP code from authenticator |
/two-factor/send-otp |
📚 Docs : https://www.better-auth.com/docs/plugins/2fa
import { organization } from "better-auth/plugins";
Organizations (10 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/create | POST | Create organization |
/organization/list | GET | List user's organizations |
/organization/get-full | GET | Get complete org details |
/organization/update | PUT | Modify organization |
/organization/delete | DELETE |
Members (8 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/list-members | GET | Get organization members |
/organization/add-member | POST | Add member directly |
/organization/remove-member | DELETE | Remove member |
/organization/update-member-role | PUT | Change member role |
/organization/get-active-member |
Invitations (7 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/invite-member | POST | Send invitation email |
/organization/accept-invitation | POST | Accept invite |
/organization/reject-invitation | POST | Reject invite |
/organization/cancel-invitation | POST | Cancel pending invite |
/organization/get-invitation |
Teams (8 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/create-team | POST | Create team within org |
/organization/list-teams | GET | List organization teams |
/organization/update-team | PUT | Modify team |
/organization/remove-team | DELETE | Remove team |
/organization/set-active-team |
Permissions & Roles (6 endpoints):
| Endpoint | Method | Description |
|---|---|---|
/organization/has-permission | POST | Check if user has permission |
/organization/create-role | POST | Create custom role |
/organization/delete-role | DELETE | Delete custom role |
/organization/list-roles | GET | List all roles |
/organization/get-role |
📚 Docs : https://www.better-auth.com/docs/plugins/organization
import { admin } from "better-auth/plugins";
// v1.4.10 configuration options
admin({
defaultRole: "user",
adminRoles: ["admin"],
adminUserIds: ["user_abc123"], // Always grant admin to specific users
impersonationSessionDuration: 3600, // 1 hour (seconds)
allowImpersonatingAdmins: false, // ⚠️ Default changed in v1.4.6
defaultBanReason: "Violation of Terms of Service",
bannedUserMessage: "Your account has been suspended",
})
| Endpoint | Method | Description |
|---|---|---|
/admin/create-user | POST | Create user as admin |
/admin/list-users | GET | List all users (with filters/pagination) |
/admin/set-role | POST | Assign user role |
/admin/set-user-password | POST | Change user password |
/admin/update-user | PUT |
⚠️ Breaking Change (v1.4.6) : allowImpersonatingAdmins now defaults to false. Set to true explicitly if you need admin-on-admin impersonation.
Custom Roles with Permissions (v1.4.10):
import { createAccessControl } from "better-auth/plugins/access";
// Define resources and permissions
const ac = createAccessControl({
user: ["create", "read", "update", "delete", "ban", "impersonate"],
project: ["create", "read", "update", "delete", "share"],
} as const);
// Create custom roles
const supportRole = ac.newRole({
user: ["read", "ban"], // Can view and ban users
project: ["read"], // Can view projects
});
const managerRole = ac.newRole({
user: ["read", "update"],
project: ["create", "read", "update", "delete"],
});
// Use in plugin
admin({
ac,
roles: {
support: supportRole,
manager: managerRole,
},
})
📚 Docs : https://www.better-auth.com/docs/plugins/admin
Passkey Plugin (5 endpoints) - Docs:
/passkey/add, /sign-in/passkey, /passkey/list, /passkey/delete, /passkey/updateMagic Link Plugin (2 endpoints) - Docs:
/sign-in/magic-link, /magic-link/verifyUsername Plugin (2 endpoints) - Docs:
/sign-in/username, /username/is-availablePhone Number Plugin (5 endpoints) - Docs:
/sign-in/phone-number, /phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-passwordEmail OTP Plugin (6 endpoints) - Docs:
/email-otp/send-verification-otp, /email-otp/check-verification-otp, /sign-in/email-otp, /email-otp/verify-email, /forget-password/email-otp, /email-otp/reset-passwordAnonymous Plugin (1 endpoint) - Docs:
/sign-in/anonymousJWT Plugin (2 endpoints) - Docs:
/token (get JWT), /jwks (public key for verification)OpenAPI Plugin (2 endpoints) - Docs:
/reference (interactive API docs with Scalar UI)/generate-openapi-schema (get OpenAPI spec as JSON)auth.api.*)Every HTTP endpoint has a corresponding server-side method. Use these for:
// Authentication
await auth.api.signUpEmail({
body: { email, password, name },
headers: request.headers,
});
await auth.api.signInEmail({
body: { email, password, rememberMe: true },
headers: request.headers,
});
await auth.api.signOut({ headers: request.headers });
// Session Management
const session = await auth.api.getSession({ headers: request.headers });
await auth.api.listSessions({ headers: request.headers });
await auth.api.revokeSession({
body: { token: "session_token_here" },
headers: request.headers,
});
// User Management
await auth.api.updateUser({
body: { name: "New Name", image: "https://..." },
headers: request.headers,
});
await auth.api.changeEmail({
body: { newEmail: "newemail@example.com" },
headers: request.headers,
});
await auth.api.deleteUser({
body: { password: "current_password" },
headers: request.headers,
});
// Account Linking
await auth.api.linkSocialAccount({
body: { provider: "google" },
headers: request.headers,
});
await auth.api.unlinkAccount({
body: { providerId: "google", accountId: "google_123" },
headers: request.headers,
});
2FA Plugin :
// Enable 2FA
const { totpUri, backupCodes } = await auth.api.enableTwoFactor({
body: { issuer: "MyApp" },
headers: request.headers,
});
// Verify TOTP code
await auth.api.verifyTOTP({
body: { code: "123456", trustDevice: true },
headers: request.headers,
});
// Generate backup codes
const { backupCodes } = await auth.api.generateBackupCodes({
headers: request.headers,
});
Organization Plugin :
// Create organization
const org = await auth.api.createOrganization({
body: { name: "Acme Corp", slug: "acme" },
headers: request.headers,
});
// Add member
await auth.api.addMember({
body: {
userId: "user_123",
role: "admin",
organizationId: org.id,
},
headers: request.headers,
});
// Check permissions
const hasPermission = await auth.api.hasPermission({
body: {
organizationId: org.id,
permission: "users:delete",
},
headers: request.headers,
});
Admin Plugin :
// List users with pagination
const users = await auth.api.listUsers({
query: {
search: "john",
limit: 10,
offset: 0,
sortBy: "createdAt",
sortOrder: "desc",
},
headers: request.headers,
});
// Ban user
await auth.api.banUser({
body: {
userId: "user_123",
reason: "Violation of ToS",
expiresAt: new Date("2025-12-31"),
},
headers: request.headers,
});
// Impersonate user (for admin support)
const impersonationSession = await auth.api.impersonateUser({
body: {
userId: "user_123",
expiresIn: 3600, // 1 hour
},
headers: request.headers,
});
| Use Case | Use HTTP Endpoints | Use auth.api.* Methods |
|---|---|---|
| Client-side auth | ✅ Yes | ❌ No |
| Server middleware | ❌ No | ✅ Yes |
| Background jobs | ❌ No | ✅ Yes |
| Admin dashboards | ✅ Yes (from client) | ✅ Yes (from server) |
| Custom auth flows | ❌ No | ✅ Yes |
| Mobile apps | ✅ Yes | ❌ No |
| API routes | ✅ Yes (proxy to handler) | ✅ Yes (direct calls) |
Example: Protected Route Middleware
import { Hono } from "hono";
import { createAuth } from "./auth";
import { createDatabase } from "./db";
const app = new Hono<{ Bindings: Env }>();
// Middleware using server-side API
app.use("/api/protected/*", async (c, next) => {
const db = createDatabase(c.env.DB);
const auth = createAuth(db, c.env);
// Use server-side method
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
// Attach to context
c.set("user", session.user);
c.set("session", session.session);
await next();
});
// Protected route
app.get("/api/protected/profile", async (c) => {
const user = c.get("user");
return c.json({ user });
});
Use the OpenAPI plugin to see all endpoints in your configuration:
import { betterAuth } from "better-auth";
import { openAPI } from "better-auth/plugins";
export const auth = betterAuth({
database: /* ... */,
plugins: [
openAPI(), // Adds /api/auth/reference endpoint
],
});
Interactive documentation : Visit http://localhost:8787/api/auth/reference
This shows a Scalar UI with:
Programmatic access :
const schema = await auth.api.generateOpenAPISchema();
console.log(JSON.stringify(schema, null, 2));
// Returns full OpenAPI 3.0 spec
Building from scratch (manual implementation):
Total manual effort : ~220 hours (5.5 weeks full-time)
With better-auth :
Total with better-auth : 4-8 hours
Savings : ~97% development time
better-auth provides 80+ production-ready endpoints covering:
You write zero endpoint code. Just configure features and call auth.handler().
Problem : Code shows import { d1Adapter } from 'better-auth/adapters/d1' but this doesn't exist.
Symptoms : TypeScript error or runtime error about missing export.
Solution : Use Drizzle or Kysely instead:
// ❌ WRONG - This doesn't exist
import { d1Adapter } from 'better-auth/adapters/d1'
database: d1Adapter(env.DB)
// ✅ CORRECT - Use Drizzle
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { drizzle } from 'drizzle-orm/d1'
const db = drizzle(env.DB, { schema })
database: drizzleAdapter(db, { provider: "sqlite" })
// ✅ CORRECT - Use Kysely
import { Kysely } from 'kysely'
import { D1Dialect } from 'kysely-d1'
database: {
db: new Kysely({ dialect: new D1Dialect({ database: env.DB }) }),
type: "sqlite"
}
Source : Verified from 4 production repositories using better-auth + D1
Problem : npx better-auth migrate doesn't create D1-compatible schema.
Symptoms : Migration SQL has wrong syntax or doesn't work with D1.
Solution : Use Drizzle Kit to generate migrations:
# Generate migration from Drizzle schema
npx drizzle-kit generate
# Apply to D1
wrangler d1 migrations apply my-app-db --remote
Why : Drizzle Kit generates SQLite-compatible SQL that works with D1.
Problem : Database has email_verified but better-auth expects emailVerified.
Symptoms : Session reads fail, user data missing fields.
⚠️ CRITICAL (v1.4.10+) : Using Kysely's CamelCasePlugin breaks join parsing in better-auth adapter. The plugin converts join keys like _joined_user_user_id to _joinedUserUserId, causing user data to be null in session queries.
Solution for Drizzle : Define schema with camelCase from the start (as shown in examples).
Solution for Kysely with CamelCasePlugin : Use separate Kysely instance without CamelCasePlugin for better-auth:
// DB for better-auth (no CamelCasePlugin)
const authDb = new Kysely({
dialect: new D1Dialect({ database: env.DB }),
})
// DB for app queries (with CamelCasePlugin)
const appDb = new Kysely({
dialect: new D1Dialect({ database: env.DB }),
plugins: [new CamelCasePlugin()],
})
export const auth = betterAuth({
database: { db: authDb, type: "sqlite" },
})
Source : GitHub Issue #7136
Problem : Session reads immediately after write return stale data.
Symptoms : User logs in but getSession() returns null on next request.
Solution : Use Cloudflare KV for session storage (strong consistency):
import { betterAuth } from "better-auth";
export function createAuth(db: Database, env: Env) {
return betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
session: {
storage: {
get: async (sessionId) => {
const session = await env.SESSIONS_KV.get(sessionId);
return session ? JSON.parse(session) : null;
},
set: async (sessionId, session, ttl) => {
await env.SESSIONS_KV.put(sessionId, JSON.stringify(session), {
expirationTtl: ttl,
});
},
delete: async (sessionId) => {
await env.SESSIONS_KV.delete(sessionId);
},
},
},
});
}
Add towrangler.toml:
[[kv_namespaces]]
binding = "SESSIONS_KV"
id = "your-kv-namespace-id"
Problem : CORS errors when auth API is on different origin than frontend.
Symptoms : Access-Control-Allow-Origin errors in browser console.
Solution : Configure CORS headers in Worker and ensure trustedOrigins match:
import { cors } from "hono/cors";
// CRITICAL: Both must match frontend origin exactly
app.use(
"/api/auth/*",
cors({
origin: "http://localhost:5173", // Frontend URL (no trailing slash)
credentials: true, // Allow cookies
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
})
);
// And in better-auth config
export const auth = betterAuth({
trustedOrigins: ["http://localhost:5173"], // Same as CORS origin
// ...
});
Common Mistakes :
trustedOriginsSource : GitHub Issue #7434
Problem : Social sign-in fails with "redirect_uri_mismatch" error.
Symptoms : Google/GitHub OAuth returns error after user consent.
Solution : Ensure exact match in OAuth provider settings:
Provider setting: https://yourdomain.com/api/auth/callback/google
better-auth URL: https://yourdomain.com/api/auth/callback/google
❌ Wrong: http vs https, trailing slash, subdomain mismatch
✅ Right: Exact character-for-character match
Check better-auth callback URL :
// It's always: {baseURL}/api/auth/callback/{provider}
const callbackURL = `${env.BETTER_AUTH_URL}/api/auth/callback/google`;
console.log("Configure this URL in Google Console:", callbackURL);
Problem : TypeScript errors or runtime errors about missing packages.
Symptoms : Cannot find module 'drizzle-orm' or similar.
Solution : Install all required packages:
For Drizzle approach :
npm install better-auth drizzle-orm drizzle-kit @cloudflare/workers-types
For Kysely approach :
npm install better-auth kysely kysely-d1 @cloudflare/workers-types
Problem : Email verification links never arrive.
Symptoms : User signs up, but no email received.
Solution : Implement sendVerificationEmail handler:
export const auth = betterAuth({
database: /* ... */,
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
// Use your email service (SendGrid, Resend, etc.)
await sendEmail({
to: user.email,
subject: "Verify your email",
html: `
<p>Click the link below to verify your email:</p>
<a href="${url}">Verify Email</a>
`,
});
},
sendOnSignUp: true,
autoSignInAfterVerification: true,
expiresIn: 3600, // 1 hour
},
});
For Cloudflare : Use Cloudflare Email Routing or external service (Resend, SendGrid).
Problem : Session expires unexpectedly or never expires.
Symptoms : User logged out unexpectedly or session persists after logout.
Solution : Configure session expiration:
export const auth = betterAuth({
database: /* ... */,
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days (in seconds)
updateAge: 60 * 60 * 24, // Update session every 24 hours
},
});
Problem : Social sign-in succeeds but missing user data (name, avatar).
Symptoms : session.user.name is null after Google/GitHub sign-in.
Solution : Request additional scopes:
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
scope: ["openid", "email", "profile"], // Include 'profile' for name/image
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["user:email", "read:user"], // 'read:user' for full profile
},
}
Problem : TypeScript complains about schema types.
Symptoms : Type 'DrizzleD1Database' is not assignable to...
Solution : Export proper types from database:
// src/db/index.ts
import { drizzle, type DrizzleD1Database } from "drizzle-orm/d1";
import * as schema from "./schema";
export type Database = DrizzleD1Database<typeof schema>;
export function createDatabase(d1: D1Database): Database {
return drizzle(d1, { schema });
}
Problem : wrangler dev fails with database errors.
Symptoms : "Database not found" or migration errors in local dev.
Solution : Apply migrations locally first:
# Apply migrations to local D1
wrangler d1 migrations apply my-app-db --local
# Then run dev server
wrangler dev
Problem : After updating user data (e.g., avatar, name), changes don't appear in useSession() despite calling queryClient.invalidateQueries().
Symptoms : Avatar image or user profile data appears stale after successful update. TanStack Query cache shows updated data, but better-auth session still shows old values.
Root Cause : better-auth uses nanostores for session state management, not TanStack Query. Calling queryClient.invalidateQueries() only invalidates React Query cache, not the better-auth nanostore.
Solution : Manually notify the nanostore after updating user data:
// Update user data
const { data, error } = await authClient.updateUser({
image: newAvatarUrl,
name: newName
})
if (!error) {
// Manually invalidate better-auth session state
authClient.$store.notify('$sessionSignal')
// Optional: Also invalidate React Query if using it for other data
queryClient.invalidateQueries({ queryKey: ['user-profile'] })
}
When to use :
Alternative : Call refetch() from useSession(), but $store.notify() is more direct:
const { data: session, refetch } = authClient.useSession()
// After update
await refetch()
Note : $store is an undocumented internal API. This pattern is production-validated but may change in future better-auth versions.
Source : Community-discovered pattern, production use verified
Problem : better-auth CLI (npx @better-auth/cli generate) fails with "Failed to initialize database adapter" when using D1.
Symptoms : CLI cannot connect to D1 to introspect schema. Running migrations through CLI doesn't work.
Root Cause : The CLI expects a direct SQLite connection, but D1 requires Cloudflare's binding API.
Solution : Skip the CLI and create migrations manually using the documented apiKey schema:
CREATE TABLE api_key (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
name TEXT,
start TEXT,
prefix TEXT,
key TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
rate_limit_enabled INTEGER,
rate_limit_time_window INTEGER,
rate_limit_max INTEGER,
request_count INTEGER DEFAULT 0,
last_request INTEGER,
remaining INTEGER,
refill_interval INTEGER,
refill_amount INTEGER,
last_refill_at INTEGER,
expires_at INTEGER,
permissions TEXT,
metadata TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
Key Points :
snake_case (e.g., rate_limit_time_window, not rateLimitTimeWindow)ALTER TABLE DROP COLUMN - if schema drifts, use fresh migration pattern (drop and recreate tables)apikey (lowercase) as the table name mappingFresh Migration Pattern for D1 :
-- Drop in reverse dependency order
DROP TABLE IF EXISTS api_key;
DROP TABLE IF EXISTS session;
-- ... other tables
-- Recreate with clean schema
CREATE TABLE api_key (...);
Source : Production debugging with D1 + better-auth apiKey plugin
Problem : Admin plugin methods like listUsers fail with "You are not allowed to list users" even though your middleware passes.
Symptoms : Custom requireAdmin middleware (checking ADMIN_EMAILS env var) passes, but auth.api.listUsers() returns 403.
Root Cause : better-auth admin plugin has two authorization layers:
user.role === 'admin' in databaseBoth must pass for admin plugin methods to work.
Solution : Set user role to 'admin' in the database:
-- Fix for existing users
UPDATE user SET role = 'admin' WHERE email = 'admin@example.com';
Or use the admin UI/API to set roles after initial setup.
Why : The admin plugin's listUsers, banUser, impersonateUser, etc. all check user.role in the database, not your custom middleware logic.
Source : Production debugging - misleading error message led to root cause discovery via wrangler tail
Problem : Organization creation fails with SQL constraint error even though API returns "slug already exists".
Symptoms :
wrangler tail shows: Failed query: insert into "organization" ... values (?, ?, ?, null, null, ?, null)Root Cause : better-auth inserts null for updated_at on creation (only sets it on updates). If your schema has NOT NULL constraint, insert fails.
Solution : Make updated_at nullable in both schema and migrations:
// Drizzle schema - CORRECT
export const organization = sqliteTable('organization', {
// ...
updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()
});
export const team = sqliteTable('team', {
// ...
updatedAt: integer('updated_at', { mode: 'timestamp' }), // No .notNull()
});
-- Migration - CORRECT
CREATE TABLE organization (
-- ...
updated_at INTEGER -- No NOT NULL
);
Applies to : organization and team tables (possibly other plugin tables)
Source : Production debugging - wrangler tail revealed actual SQL error behind misleading "slug exists" message
Problem : Custom API endpoints return double-nested data like { members: { members: [...], total: N } }.
Symptoms : UI shows "undefined" for counts, empty lists despite data existing.
Root Cause : better-auth methods like listMembers return { members: [...], total: N }. Wrapping with c.json({ members: result }) creates double nesting.
Solution : Extract the array from better-auth response:
// ❌ WRONG - Double nesting
const result = await auth.api.listMembers({ ... });
return c.json({ members: result });
// Returns: { members: { members: [...], total: N } }
// ✅ CORRECT - Extract array
const result = await auth.api.listMembers({ ... });
const members = result?.members || [];
return c.json({ members });
// Returns: { members: [...] }
Affected methods (return objects, not arrays):
listMembers → { members: [...], total: N }listUsers → { users: [...], total: N, limit: N }listOrganizations → { organizations: [...] } (check structure)listInvitations → { invitations: [...] }Pattern : Always check better-auth method return types before wrapping in your API response.
Source : Production debugging - UI showed "undefined" count, API inspection revealed nesting issue
Problem : Importing expoClient from @better-auth/expo/client crashes with TypeError: Cannot read property 'fromJSONSchema' of undefined on v1.4.16.
Symptoms : Runtime crash immediately when importing expoClient in React Native/Expo apps.
Root Cause : Regression introduced after PR #6933 (cookie-based OAuth state fix for Expo). One of 3 commits after f4a9f15 broke the build.
Solution :
Temporary : Use continuous build at commit f4a9f15 (pre-regression)
Permanent : Wait for fix (issue #7491 open as of 2026-01-20)
// Crashes on v1.4.16 import { expoClient } from '@better-auth/expo/client'
// Workaround: Use continuous build at f4a9f15 // Or wait for fix in next release
Source : GitHub Issue #7491
Problem : After v1.4.12, additionalFields with type: 'string[]' return stringified arrays ('["a","b"]') instead of native arrays when querying via Drizzle directly.
Symptoms : user.notificationTokens is a string, not an array. Code expecting arrays breaks.
Root Cause : In Drizzle adapter, string[] fields are stored with mode: 'json', which expects arrays. But better-auth v1.4.4+ passes strings to Drizzle, causing double-stringification. When querying directly via Drizzle , the value is a string, but when using better-authinternalAdapter, a transformer correctly returns an array.
Solution :
internalAdapter instead of querying Drizzle directly (has transformer).jsonb() for string[] fields// Config
additionalFields: {
notificationTokens: {
type: 'string[]',
required: true,
input: true,
},
}
// Create user
notificationTokens: ['token1', 'token2']
// Result in DB (when querying via Drizzle directly)
// '["token1","token2"]' (string, not array)
Source : GitHub Issue #7440
Problem : Setting returned: false on additionalFields prevents field from being saved via API, even with input: true.
Symptoms : Field never saved to database when creating/updating via API endpoints.
Root Cause : The returned: false property blocks both read AND write operations, not just reads as intended. The input: true property should control write access independently.
Solution :
Don't use returned: false if you need API write access
Write via server-side methods (auth.api.*) instead
// Organization plugin config additionalFields: { secretField: { type: 'string', required: true, input: true, // Should allow API writes returned: false, // Should only block reads, but blocks writes too }, }
// API request to create organization // secretField is never saved to database
Source : GitHub Issue #7489
Problem : session.freshAge checks time-since-creation, NOT recent activity. Active sessions become "not fresh" after freshAge elapses, even if used constantly.
Symptoms : "Fresh session required" endpoints reject valid active sessions.
Why It Happens : The freshSessionMiddleware checks Date.now() - (session.updatedAt || session.createdAt), but updatedAt only changes when the session is refreshed based on updateAge. If updateAge > freshAge, the session becomes "not fresh" before updatedAt is bumped.
Solution :
updateAge <= freshAge to ensure freshness is updated before expiry// Config
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
freshAge: 60 * 60 * 24, // 24 hours
updateAge: 60 * 60 * 24 * 3, // 3 days (> freshAge!) ⚠️ PROBLEM
// CORRECT - updateAge <= freshAge
updateAge: 60 * 60 * 12, // 12 hours (< freshAge)
}
// Timeline with bad config:
// T+0h: User signs in (createdAt = now)
// T+12h: User makes requests (session active, still fresh)
// T+25h: User makes request (session active, BUT NOT FRESH - freshAge elapsed)
// Result: "Fresh session required" endpoints reject active session
Source : GitHub Issue #7472
Problem : OAuth 2.1 and OIDC token endpoints return { "response": { ...tokens... } } instead of spec-compliant top-level JSON. OAuth clients expect { "access_token": "...", "token_type": "bearer" } at root.
Symptoms : OAuth clients fail with Bearer undefined or invalid_token.
Root Cause : The endpoint pipeline returns { response, headers, status } for internal use, which gets serialized directly for HTTP requests. This breaks OAuth/OIDC spec requirements.
Solution :
Temporary : Manually unwrap .response field on client
Permanent : Wait for fix (issue #7355 open, accepting contributions)
// Expected (spec-compliant) { "access_token": "...", "token_type": "bearer", "expires_in": 3600 }
// Actual (wrapped) { "response": { "access_token": "...", "token_type": "bearer", "expires_in": 3600 } }
// Result: OAuth clients fail to parse, send Bearer undefined
Source : GitHub Issue #7355
Key differences :
Migration steps :
Export user data from Clerk (CSV or API)
Import into better-auth database :
// migration script const clerkUsers = await fetchClerkUsers();
for (const clerkUser of clerkUsers) {
await db.insert(user).values({
id: clerkUser.id,
email: clerkUser.email,
emailVerified: clerkUser.email_verified,
name: clerkUser.first_name + " " + clerkUser.last_name,
image: clerkUser.profile_image_url,
});
}
3. Replace Clerk SDK with better-auth client:
// Before (Clerk)
import { useUser } from "@clerk/nextjs";
const { user } = useUser();
// After (better-auth)
import { authClient } from "@/lib/auth-client";
const { data: session } = authClient.useSession();
const user = session?.user;
4. Update middleware for session verification 5. Configure social providers (same OAuth apps, different config)
Key differences :
Migration steps :
Database schema : Auth.js and better-auth use similar schemas, but column names differ
Replace configuration :
// Before (Auth.js) import NextAuth from "next-auth"; import GoogleProvider from "next-auth/providers/google";
export default NextAuth({
providers: [GoogleProvider({ /* ... */ })],
});
// After (better-auth)
import { betterAuth } from "better-auth";
export const auth = betterAuth({
socialProviders: {
google: { /* ... */ },
},
});
3. Update client hooks :
// Before
import { useSession } from "next-auth/react";
// After
import { authClient } from "@/lib/auth-client";
const { data: session } = authClient.useSession();
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
106,200 周安装
| MCP | better-auth/plugins | ⚠️ Deprecated - Use OAuth 2.1 Provider instead | 📚 |
| Expo | better-auth/expo | React Native/Expo with webBrowserOptions and last-login-method tracking | 📚 |
| 📚 |
| Multi-Session | Multiple accounts in same browser | 📚 |
| API Key | Token-based auth with rate limits | 📚 |
| Microsoft | openid | User ID only |
email | Email address |
profile | Name, locale |
User.Read | Full profile from Graph API |
| Discord | identify | Username, avatar, discriminator |
email | Email address |
| Apple | name | First/last name (first auth only) |
email | Email or relay address |
| Patreon | identity | User ID, name |
identity[email] | Email address |
| Vercel | (auto) | Email, name, avatar |
| Initiate password reset flow |
/reset-password | POST | Complete password reset with token |
/send-verification-email | POST | Send email verification link |
/verify-email | GET | Verify email with token (?token=<token>) |
/get-session | GET | Retrieve current session |
/list-sessions | GET | Get all active user sessions |
/revoke-session | POST | End specific session |
/revoke-other-sessions | POST | End all sessions except current |
/revoke-sessions | POST | End all user sessions |
/update-user | POST | Modify user profile (name, image) |
/change-email | POST | Update email address |
/set-password | POST | Add password to OAuth-only account |
/delete-user | POST | Remove user account |
/list-accounts | GET | Get linked authentication providers |
/link-social | POST | Connect OAuth provider to account |
/unlink-account | POST | Disconnect provider |
| POST |
| Send OTP via email |
/two-factor/verify-otp | POST | Validate email OTP |
/two-factor/generate-backup-codes | POST | Create recovery codes |
/two-factor/verify-backup-code | POST | Use backup code for login |
/two-factor/view-backup-codes | GET | View current backup codes |
| Remove organization |
/organization/check-slug | GET | Verify slug availability |
/organization/set-active | POST | Set active organization context |
| GET |
| Get current member info |
/organization/leave | POST | Leave organization |
| GET |
| Get invitation details |
/organization/list-invitations | GET | List org invitations |
/organization/list-user-invitations | GET | List user's pending invites |
| POST |
| Set active team context |
/organization/list-team-members | GET | List team members |
/organization/add-team-member | POST | Add member to team |
/organization/remove-team-member | DELETE | Remove team member |
| GET |
| Get role details |
/organization/update-role | PUT | Modify role permissions |
| Modify user details |
/admin/remove-user | DELETE | Delete user account |
/admin/ban-user | POST | Ban user account (with optional expiry) |
/admin/unban-user | POST | Unban user |
/admin/list-user-sessions | GET | Get user's active sessions |
/admin/revoke-user-session | DELETE | End specific user session |
/admin/revoke-user-sessions | DELETE | End all user sessions |
/admin/impersonate-user | POST | Start impersonating user |
/admin/stop-impersonating | POST | End impersonation session |