Better Auth 完整接入指南
1. 接入目标
本指南最终实现以下能力:
- 邮箱和密码注册。
- 邮箱和密码登录。
- Cookie Session。
- 邮箱验证。
- 忘记密码和重置密码。
- Google、GitHub OAuth。
- Cloudflare Turnstile 防滥用。
- Hono 服务端鉴权中间件。
- React 客户端 Session 状态。
- 登录设备和 Session 撤销。
- D1 数据持久化。
- Drizzle Schema 和 Migration。
- Cloudflare Workers 部署。
- 可继续扩展 Organization、Admin、API Key、Passkey 和 2FA。
推荐拓扑:
Browser
│
├── React SPA
│ ├── Better Auth React Client
│ └── Hono RPC Client
│
▼
Cloudflare Worker
│
├── /api/auth/* Better Auth
├── /api/v1/* Hono Business API
└── /* React Static Assets
│
▼
Cloudflare D1
├── user
├── account
├── session
├── verification
├── rateLimit
└── business tables
Better Auth 本身基于标准 Request 和 Response,在 Hono 中可以直接把原始请求交给 auth.handler(),不需要 Express 或 Node.js 中间件转换。
2. 核心设计决策
2.1 同域优先
推荐生产结构:
https://app.example.com/
https://app.example.com/login
https://app.example.com/dashboard
https://app.example.com/api/auth/*
https://app.example.com/api/v1/*
同域部署的优势:
- 不需要额外配置 CORS。
- Cookie 是第一方 Cookie。
- 不需要
SameSite=None。 - 不需要跨子域 Cookie。
- OAuth Callback URL 更容易管理。
- 本地和生产环境差异更小。
Cloudflare 官方 React 模板可以在一个 Workers 项目中同时托管 React SPA 和 Worker API;SPA 静态路由可由资产层处理,未命中静态资源时再进入 Worker。
2.2 Session 保存在 D1
推荐:
D1 Session 真相源
短期签名 Cookie Cache 减少 Session 查询
暂不推荐:
Workers KV Session 真相源
纯 Stateless Session 默认认证模型
JWT 浏览器登录 Session
Better Auth 默认采用数据库 Session,并支持签名 Cookie Cache。Cookie Cache 可以减少数据库访问,但已撤销 Session 可能在缓存过期前继续有效,因此应保持较短的缓存时间。
2.3 Better Auth 是唯一认证所有者
项目中只维护一个认证配置来源:
Better Auth
├── 负责注册登录
├── 负责 Session
├── 负责 OAuth
├── 负责验证邮件
└── 负责密码重置
Hono 不应该再单独实现另一套密码、Token 或 Session 系统。
3. 创建项目
使用 Cloudflare 官方 React 模板:
pnpm create cloudflare@latest better-auth-app --framework=react
cd better-auth-app
Cloudflare 当前的 React 模板会创建 React SPA、Worker 后端和 Cloudflare Vite Plugin 配置,本地 Worker 运行在接近生产环境的 Workers Runtime 中。
安装运行依赖:
pnpm add \
hono \
better-auth \
@better-auth/drizzle-adapter \
drizzle-orm \
zod \
@hono/zod-validator
安装开发依赖:
pnpm add -D \
@better-auth/cli \
drizzle-kit \
better-sqlite3 \
@types/better-sqlite3
当前 Better Auth 官方 Drizzle Adapter 已独立为:
@better-auth/drizzle-adapter
使用 Drizzle 等 ORM Adapter 时,可以从 better-auth/minimal 导入核心,以避免将不需要的 Kysely 实现打进 Worker Bundle。
建议锁定精确版本:
{
"dependencies": {
"better-auth": "1.6.x",
"@better-auth/drizzle-adapter": "1.6.x"
},
"devDependencies": {
"@better-auth/cli": "1.6.x"
}
}
实际项目中应让核心包、Adapter 和 CLI 使用相同版本,不要在 CI 中长期使用不固定的 latest。
4. 推荐目录结构
better-auth-app/
├── src/
│ ├── client/
│ │ ├── components/
│ │ ├── features/
│ │ │ └── auth/
│ │ ├── routes/
│ │ ├── lib/
│ │ │ ├── auth-client.ts
│ │ │ └── api-client.ts
│ │ └── main.tsx
│ │
│ ├── db/
│ │ ├── client.ts
│ │ └── schema/
│ │ ├── auth.ts
│ │ ├── business.ts
│ │ └── index.ts
│ │
│ └── shared/
│ └── schemas/
│
├── worker/
│ ├── auth/
│ │ ├── options.ts
│ │ ├── create-auth.ts
│ │ ├── middleware.ts
│ │ └── email.ts
│ ├── routes/
│ ├── env.ts
│ └── index.ts
│
├── drizzle/
├── better-auth.config.ts
├── drizzle.config.ts
├── wrangler.jsonc
└── package.json
这里将以下内容分开:
options.ts 与环境无关的认证规则
create-auth.ts 绑定 D1、Secret、OAuth 等运行时资源
better-auth.config.ts Better Auth CLI Schema 生成配置
auth.ts CLI 生成的 Drizzle Schema
5. 创建 D1 数据库
pnpm wrangler d1 create better-auth-app
命令会返回数据库 ID,将其填入 wrangler.jsonc。
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "better-auth-app",
"main": "worker/index.ts",
"compatibility_date": "2026-07-14",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"not_found_handling": "single-page-application"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "better-auth-app",
"database_id": "REPLACE_WITH_DATABASE_ID",
"migrations_dir": "drizzle",
"migrations_pattern": "drizzle/*/migration.sql"
}
],
"vars": {
"APP_URL": "https://app.example.com",
"BETTER_AUTH_URL": "https://app.example.com",
"APP_ENV": "production"
},
"observability": {
"enabled": true
}
}
D1 使用 SQLite SQL 语义,并通过 Workers Binding 或 HTTP API 访问。Cloudflare 当前也支持用 migrations_pattern 识别 Drizzle 生成的嵌套迁移目录。
6. 环境变量与 Secret
6.1 Worker 环境类型
// worker/env.ts
export interface Env {
DB: D1Database;
APP_ENV: "development" | "staging" | "production";
APP_URL: string;
BETTER_AUTH_URL: string;
BETTER_AUTH_SECRET: string;
TURNSTILE_SECRET_KEY?: string;
GOOGLE_CLIENT_ID?: string;
GOOGLE_CLIENT_SECRET?: string;
GITHUB_CLIENT_ID?: string;
GITHUB_CLIENT_SECRET?: string;
EMAIL_API_KEY?: string;
}
生成 Cloudflare 类型:
pnpm wrangler types
6.2 本地变量
创建 .dev.vars:
APP_ENV=development
APP_URL=http://localhost:5173
BETTER_AUTH_URL=http://localhost:5173
BETTER_AUTH_SECRET=replace-with-at-least-32-characters
TURNSTILE_SECRET_KEY=replace-with-test-secret
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
EMAIL_API_KEY=
6.3 生产 Secret
pnpm wrangler secret put BETTER_AUTH_SECRET
pnpm wrangler secret put TURNSTILE_SECRET_KEY
pnpm wrangler secret put GOOGLE_CLIENT_ID
pnpm wrangler secret put GOOGLE_CLIENT_SECRET
pnpm wrangler secret put GITHUB_CLIENT_ID
pnpm wrangler secret put GITHUB_CLIENT_SECRET
pnpm wrangler secret put EMAIL_API_KEY
Better Auth Secret 至少应有 32 个字符并使用高熵随机值。Better Auth 还支持通过多版本 Secret 进行无中断密钥轮换。
生成 Secret:
openssl rand -base64 32
不得提交:
.dev.vars
.env
OAuth Client Secret
BETTER_AUTH_SECRET
Turnstile Secret
邮件服务 API Key
7. 配置 Drizzle
7.1 数据库客户端
// src/db/client.ts
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function createDb(database: D1Database) {
return drizzle(database, {
schema,
casing: "snake_case",
});
}
export type Database = ReturnType<typeof createDb>;
7.2 Schema 入口
// src/db/schema/index.ts
export * from "./auth";
export * from "./business";
7.3 Drizzle Kit 配置
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema/*.ts",
out: "./drizzle",
dialect: "sqlite",
strict: true,
verbose: true,
});
Drizzle 对 D1 使用 SQLite 方言,运行时通过 drizzle-orm/d1 和 D1 Binding 创建数据库实例。
8. 定义共享 Better Auth 配置
将不依赖 Cloudflare Binding 的配置放入独立文件。
// worker/auth/options.ts
import type { BetterAuthOptions } from "better-auth";
export const baseAuthOptions = {
appName: "Birdor App",
basePath: "/api/auth",
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 10,
maxPasswordLength: 128,
autoSignIn: false,
revokeSessionsOnPasswordReset: true,
resetPasswordTokenExpiresIn: 60 * 60,
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
autoSignInAfterVerification: false,
expiresIn: 60 * 60,
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
freshAge: 60 * 10,
cookieCache: {
enabled: true,
maxAge: 60 * 3,
strategy: "compact",
},
},
account: {
encryptOAuthTokens: true,
accountLinking: {
enabled: true,
trustedProviders: [
"google",
"github",
"email-password",
],
allowDifferentEmails: false,
},
},
rateLimit: {
enabled: true,
storage: "database",
},
advanced: {
cookiePrefix: "birdor",
database: {
generateId: "uuid",
},
},
} satisfies BetterAuthOptions;
Better Auth 默认最小密码长度是 8,默认 Session 有效期为 7 天、刷新周期为 1 天。这里将密码最小长度提升到 10,并将敏感 Session Freshness 设置为 10 分钟。
开启:
account.encryptOAuthTokens = true
可以让 OAuth Token 在存入数据库前加密;该选项默认并不是开启状态。
9. 生成 Better Auth 数据库 Schema
Better Auth 核心表通常包括:
user
session
account
verification
启用数据库限流后还会增加:
rateLimit
插件可能继续增加 Organization、Member、Invitation、API Key、Passkey 和 2FA 等表。Better Auth 的 generate 命令会根据认证配置和插件生成对应的 ORM Schema;对于 Drizzle,迁移仍需交给 Drizzle 处理。
9.1 创建 CLI 专用配置
D1 Binding 只在 Workers Runtime 中存在,因此 CLI 不应直接依赖远程 D1。可以使用本地内存 SQLite 创建一个只用于 Schema 生成的 Drizzle 实例。
// better-auth.config.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { baseAuthOptions } from "./worker/auth/options";
const sqlite = new Database(":memory:");
const db = drizzle(sqlite);
export const auth = betterAuth({
...baseAuthOptions,
database: drizzleAdapter(db, {
provider: "sqlite",
}),
baseURL: "http://localhost:5173",
// 仅用于 CLI 加载配置,不进入生产环境。
secret:
process.env.BETTER_AUTH_SECRET ??
"local-cli-secret-at-least-32-characters",
});
这个数据库只用于让 CLI 识别 SQLite/Drizzle Adapter;生产数据仍存储在 D1。
9.2 生成 Auth Schema
pnpm exec auth generate \
--config ./better-auth.config.ts \
--output ./src/db/schema/auth.ts \
--yes
9.3 生成 SQL Migration
pnpm drizzle-kit generate
9.4 应用本地 Migration
pnpm wrangler d1 migrations apply DB --local
9.5 应用生产 Migration
pnpm wrangler d1 migrations apply DB --remote
Better Auth 的 migrate 主要面向内置 Kysely Adapter;使用 Drizzle 时应先生成 Schema,再由 Drizzle 和 D1 Migration 流程应用数据库变更。
建议脚本:
{
"scripts": {
"auth:schema": "auth generate --config ./better-auth.config.ts --output ./src/db/schema/auth.ts --yes",
"db:generate": "drizzle-kit generate",
"db:migrate:local": "wrangler d1 migrations apply DB --local",
"db:migrate:remote": "wrangler d1 migrations apply DB --remote"
}
}
完整变更流程:
pnpm auth:schema
pnpm db:generate
pnpm db:migrate:local
pnpm typecheck
pnpm test
pnpm db:migrate:remote
每次增加或删除 Better Auth 插件后,都必须重新运行 Schema 生成和 Migration。
10. 邮件发送抽象
Better Auth 负责:
- 创建验证 Token。
- 创建密码重置 Token。
- 验证 Token。
- 更新用户和密码。
它不会自动替你发送邮件,需要接入邮件服务。
// worker/auth/email.ts
import type { Env } from "../env";
interface SendEmailInput {
to: string;
subject: string;
html: string;
}
export async function sendEmail(
env: Env,
input: SendEmailInput,
): Promise<void> {
if (!env.EMAIL_API_KEY) {
if (env.APP_ENV === "development") {
console.log("Development email:", input);
return;
}
throw new Error("EMAIL_API_KEY is not configured");
}
// 在这里调用 Resend、Postmark、SES 或内部邮件服务。
// 不要在日志中输出验证 Token 或重置 Token。
}
邮件发送失败应记录结构化日志,但不要记录完整的验证地址、Token 或密码重置 Token。
11. 创建运行时 Better Auth 实例
// worker/auth/create-auth.ts
import { betterAuth } from "better-auth/minimal";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { captcha } from "better-auth/plugins";
import { createDb } from "../../src/db/client";
import * as schema from "../../src/db/schema";
import { baseAuthOptions } from "./options";
import { sendEmail } from "./email";
import type { Env } from "../env";
export function createAuth(
env: Env,
executionCtx?: ExecutionContext,
) {
const db = createDb(env.DB);
function runInBackground(task: Promise<unknown>) {
if (executionCtx) {
executionCtx.waitUntil(task);
return;
}
void task;
}
return betterAuth({
...baseAuthOptions,
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
trustedOrigins: [env.APP_URL],
database: drizzleAdapter(db, {
provider: "sqlite",
schema,
}),
emailVerification: {
...baseAuthOptions.emailVerification,
sendVerificationEmail: async ({ user, url }) => {
runInBackground(
sendEmail(env, {
to: user.email,
subject: "验证你的邮箱",
html: `
<p>你好,${escapeHtml(user.name)}</p>
<p>请点击下面的链接验证邮箱:</p>
<p><a href="${escapeHtml(url)}">验证邮箱</a></p>
<p>该链接将在一小时后失效。</p>
`,
}),
);
},
},
emailAndPassword: {
...baseAuthOptions.emailAndPassword,
sendResetPassword: async ({ user, url }) => {
runInBackground(
sendEmail(env, {
to: user.email,
subject: "重置密码",
html: `
<p>我们收到了你的密码重置请求。</p>
<p><a href="${escapeHtml(url)}">重置密码</a></p>
<p>如果不是你本人操作,请忽略此邮件。</p>
`,
}),
);
},
onPasswordReset: async ({ user }) => {
console.log(
JSON.stringify({
event: "auth.password_reset",
userId: user.id,
}),
);
},
},
socialProviders: {
...(env.GOOGLE_CLIENT_ID &&
env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
...(env.GITHUB_CLIENT_ID &&
env.GITHUB_CLIENT_SECRET
? {
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
}
: {}),
},
plugins: [
...(env.TURNSTILE_SECRET_KEY
? [
captcha({
provider: "cloudflare-turnstile",
secretKey: env.TURNSTILE_SECRET_KEY,
}),
]
: []),
],
});
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export type Auth = ReturnType<typeof createAuth>;
Better Auth 官方建议在 Serverless 环境中避免让密码重置邮件发送时长暴露用户是否存在,可通过 waitUntil 或类似机制异步发送邮件。
12. 挂载到 Hono
// worker/index.ts
import { Hono } from "hono";
import { secureHeaders } from "hono/secure-headers";
import { createAuth } from "./auth/create-auth";
import type { Env } from "./env";
const app = new Hono<{
Bindings: Env;
}>();
app.use("*", secureHeaders());
app.on(
["GET", "POST"],
"/api/auth/*",
(c) => {
const auth = createAuth(
c.env,
c.executionCtx,
);
return auth.handler(c.req.raw);
},
);
app.get("/health", (c) => {
return c.json({
status: "ok",
timestamp: new Date().toISOString(),
});
});
export default app;
挂载路径必须和 Better Auth 的 basePath 一致:
配置:/api/auth
路由:/api/auth/*
Better Auth 官方 Hono 接入方式就是:
auth.handler(c.req.raw)
并通过 app.on(["GET", "POST"], "/api/auth/*", ...) 接管认证端点。
13. 创建 Hono Session 中间件
// worker/auth/middleware.ts
import { createMiddleware } from "hono/factory";
import {
createAuth,
type Auth,
} from "./create-auth";
import type { Env } from "../env";
type AuthSession = Auth["$Infer"]["Session"];
export interface AuthVariables {
user: AuthSession["user"] | null;
session: AuthSession["session"] | null;
}
export const loadSession = createMiddleware<{
Bindings: Env;
Variables: AuthVariables;
}>(async (c, next) => {
const auth = createAuth(
c.env,
c.executionCtx,
);
const result = await auth.api.getSession({
headers: c.req.raw.headers,
});
c.set("user", result?.user ?? null);
c.set("session", result?.session ?? null);
await next();
});
export const requireAuth = createMiddleware<{
Bindings: Env;
Variables: AuthVariables;
}>(async (c, next) => {
const user = c.get("user");
if (!user) {
return c.json(
{
error: {
code: "UNAUTHORIZED",
message: "Authentication required",
},
},
401,
);
}
await next();
});
应用到业务路由:
import { Hono } from "hono";
import {
loadSession,
requireAuth,
type AuthVariables,
} from "../auth/middleware";
import type { Env } from "../env";
const api = new Hono<{
Bindings: Env;
Variables: AuthVariables;
}>();
api.use("*", loadSession);
api.get("/public", (c) => {
return c.json({ public: true });
});
api.get("/profile", requireAuth, (c) => {
return c.json({
user: c.get("user"),
});
});
app.route("/api/v1", api);
Better Auth 官方建议通过 auth.api.getSession() 读取请求中的 Cookie,并把 user 和 session 放入 Hono Context。
不要只在 React 中隐藏页面或按钮。真正的安全边界必须位于服务端:
React Route Guard 改善用户体验
requireAuth 阻止未登录请求
Permission Check 阻止无权限请求
Resource Ownership 阻止水平越权
14. React Auth Client
// src/client/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
同域部署时不需要配置 baseURL。如果前后端分离:
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL,
fetchOptions: {
credentials: "include",
},
});
Better Auth React Client 提供响应式 useSession,Session 改变后会驱动组件重新渲染。
15. 注册功能
import { useState } from "react";
import { authClient } from "../lib/auth-client";
export function RegisterForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] =
useState("");
const [captchaToken, setCaptchaToken] =
useState("");
const [error, setError] = useState("");
const [pending, setPending] =
useState(false);
async function submit(
event: React.FormEvent<HTMLFormElement>,
) {
event.preventDefault();
setPending(true);
setError("");
try {
const result =
await authClient.signUp.email({
name,
email,
password,
callbackURL: "/dashboard",
fetchOptions: {
headers: {
"x-captcha-response":
captchaToken,
},
},
});
if (result.error) {
setError(
result.error.message ?? "注册失败",
);
return;
}
window.location.href =
"/verify-email-sent";
} finally {
setPending(false);
}
}
return (
<form onSubmit={submit}>
<input
value={name}
onChange={(e) =>
setName(e.target.value)
}
placeholder="姓名"
autoComplete="name"
required
/>
<input
type="email"
value={email}
onChange={(e) =>
setEmail(e.target.value)
}
placeholder="邮箱"
autoComplete="email"
required
/>
<input
type="password"
value={password}
onChange={(e) =>
setPassword(e.target.value)
}
placeholder="密码"
autoComplete="new-password"
minLength={10}
required
/>
{/* Turnstile 成功后调用:
setCaptchaToken(token)
*/}
<button
type="submit"
disabled={pending}
>
{pending ? "注册中" : "注册"}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
注册接口:
POST /api/auth/sign-up/email
启用 requireEmailVerification 后,在用户邮箱完成验证前不会创建正常登录 Session。注册是否自动登录还受到 autoSignIn 配置影响。
16. 登录功能
import { useState } from "react";
import { authClient } from "../lib/auth-client";
export function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] =
useState("");
const [captchaToken, setCaptchaToken] =
useState("");
const [error, setError] = useState("");
const [pending, setPending] =
useState(false);
async function submit(
event: React.FormEvent<HTMLFormElement>,
) {
event.preventDefault();
setPending(true);
setError("");
try {
const result =
await authClient.signIn.email({
email,
password,
callbackURL: "/dashboard",
fetchOptions: {
headers: {
"x-captcha-response":
captchaToken,
},
},
});
if (result.error) {
setError(
result.error.message ?? "登录失败",
);
return;
}
window.location.href = "/dashboard";
} finally {
setPending(false);
}
}
return (
<form onSubmit={submit}>
<input
type="email"
value={email}
onChange={(e) =>
setEmail(e.target.value)
}
autoComplete="email"
required
/>
<input
type="password"
value={password}
onChange={(e) =>
setPassword(e.target.value)
}
autoComplete="current-password"
required
/>
<button
type="submit"
disabled={pending}
>
{pending ? "登录中" : "登录"}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
登录接口:
POST /api/auth/sign-in/email
Better Auth 默认使用 scrypt 进行密码哈希;没有明确迁移需求时,不建议自行替换密码哈希实现。
17. Session 和退出
17.1 获取 Session
import { authClient } from "../lib/auth-client";
export function UserMenu() {
const {
data: session,
isPending,
error,
} = authClient.useSession();
if (isPending) {
return <div>加载中</div>;
}
if (error || !session) {
return <a href="/login">登录</a>;
}
return (
<div>
<span>{session.user.email}</span>
<button
onClick={async () => {
await authClient.signOut();
window.location.href = "/login";
}}
>
退出
</button>
</div>
);
}
17.2 强制访问数据库验证
删除账号、修改邮箱、修改支付方式等敏感操作,不应只依赖 Cookie Cache:
const result =
await authClient.getSession({
query: {
disableCookieCache: true,
},
});
服务端:
const result =
await auth.api.getSession({
headers: c.req.raw.headers,
query: {
disableCookieCache: true,
},
});
Better Auth 支持通过 disableCookieCache: true 强制从数据库读取并刷新 Session。
18. 邮箱验证
服务端已经配置:
emailAndPassword: {
requireEmailVerification: true,
},
emailVerification: {
sendOnSignUp: true,
expiresIn: 60 * 60,
}
手动重新发送验证邮件:
const result =
await authClient.sendVerificationEmail({
email,
callbackURL: "/dashboard",
});
if (result.error) {
throw new Error(result.error.message);
}
Better Auth 的验证邮件回调会收到:
user
url
token
通常直接将 url 发送给用户即可,不要自行重新拼接 Token。
建议页面:
/register
/verify-email-sent
/email-verified
/email-verification-error
邮件验证接口本身由 Better Auth 处理,前端通常只负责展示结果和重新发送入口。
19. 忘记密码与重置密码
19.1 请求重置邮件
const result =
await authClient.requestPasswordReset({
email,
redirectTo: `${window.location.origin}/reset-password`,
});
if (result.error) {
// 前端避免显示“该邮箱不存在”。
console.error(result.error);
}
无论邮箱是否存在,都应向用户展示近似信息:
如果该邮箱已注册,我们会发送密码重置邮件。
这样可以降低账号枚举风险。
19.2 重置密码页面
用户点击邮件后会进入:
/reset-password?token=...
import { useState } from "react";
import { authClient } from "../lib/auth-client";
export function ResetPasswordPage() {
const token = new URLSearchParams(
window.location.search,
).get("token");
const [password, setPassword] =
useState("");
const [error, setError] = useState("");
async function submit(
event: React.FormEvent,
) {
event.preventDefault();
if (!token) {
setError("重置链接无效");
return;
}
const result =
await authClient.resetPassword({
token,
newPassword: password,
});
if (result.error) {
setError(
result.error.message ??
"密码重置失败",
);
return;
}
window.location.href =
"/login?passwordReset=success";
}
return (
<form onSubmit={submit}>
<input
type="password"
value={password}
onChange={(e) =>
setPassword(e.target.value)
}
minLength={10}
autoComplete="new-password"
required
/>
<button type="submit">
设置新密码
</button>
{error && <p>{error}</p>}
</form>
);
}
Better Auth 支持配置重置 Token 有效期,并可以在密码重置后撤销其他 Session。
20. Google OAuth
20.1 Google Console Callback URL
本地:
http://localhost:5173/api/auth/callback/google
生产:
https://app.example.com/api/auth/callback/google
如果修改了 basePath,Callback URL 也必须同步修改。Better Auth 使用 baseURL 构造 OAuth Callback URL,配置错误通常会导致 redirect_uri_mismatch。
20.2 前端登录
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
errorCallbackURL:
"/login?oauthError=google",
newUserCallbackURL: "/welcome",
});
21. GitHub OAuth
GitHub OAuth Callback:
http://localhost:5173/api/auth/callback/github
https://app.example.com/api/auth/callback/github
前端:
await authClient.signIn.social({
provider: "github",
callbackURL: "/dashboard",
errorCallbackURL:
"/login?oauthError=github",
});
GitHub Provider 的 Callback URL 同样必须与 Better Auth 的 baseURL 和 basePath 一致。
OAuth 账号绑定安全
推荐:
accountLinking: {
enabled: true,
trustedProviders: [
"google",
"github",
"email-password",
],
allowDifferentEmails: false,
}
不要默认允许不同邮箱的 Provider 自动合并到同一账号。涉及企业账户时,还应考虑关闭隐式账号绑定,改为用户登录后主动绑定。
22. Cloudflare Turnstile
Better Auth Captcha Plugin 默认保护以下端点:
/sign-up/email
/sign-in/email
/request-password-reset
插件会在服务端调用 Captcha Provider 的验证接口。Token 缺失、验证失败或验证服务不可用时,请求都会被阻止。
前端需要将 Turnstile Token 放入:
x-captcha-response: TOKEN
示例:
await authClient.signIn.email({
email,
password,
fetchOptions: {
headers: {
"x-captcha-response":
turnstileToken,
},
},
});
Turnstile Token:
- 有效期为 5 分钟。
- 只能验证一次。
- 过期或重复使用会失败。
- 必须在服务端完成验证。
- 不能只依赖前端 Widget。
提交后应重置 Widget 并清空旧 Token:
setTurnstileToken("");
turnstile.reset(widgetId);
23. CORS 与 Cookie
23.1 同域部署
同域情况下不需要 CORS:
Frontend: https://app.example.com
API: https://app.example.com/api
这是推荐结构。
23.2 本地不同端口
例如:
React: http://localhost:5173
Hono: http://localhost:8787
Hono CORS 必须在 Better Auth 路由前注册:
import { cors } from "hono/cors";
app.use(
"/api/*",
cors({
origin: "http://localhost:5173",
allowHeaders: [
"Content-Type",
"Authorization",
"x-captcha-response",
],
allowMethods: [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
],
credentials: true,
maxAge: 600,
}),
);
app.on(
["GET", "POST"],
"/api/auth/*",
(c) => {
return createAuth(
c.env,
c.executionCtx,
).handler(c.req.raw);
},
);
客户端:
export const authClient =
createAuthClient({
baseURL: "http://localhost:8787",
fetchOptions: {
credentials: "include",
},
});
注意:
CORS origin
Better Auth trustedOrigins
fetch credentials
三项必须同时配置正确。Better Auth 官方明确要求 CORS 中间件注册在认证路由之前。
23.3 跨子域
app.example.com
api.example.com
配置:
advanced: {
crossSubDomainCookies: {
enabled: true,
domain: "example.com",
},
}
Better Auth 默认 Cookie 使用 SameSite=Lax。官方建议优先使用同域或同一主域的子域,而不是完全无关的跨域部署。
24. Session 管理页面
列出当前用户的登录设备:
const result =
await authClient.listSessions();
if (result.data) {
console.log(result.data);
}
撤销某个 Session:
await authClient.revokeSession({
token: sessionToken,
});
撤销其他设备:
await authClient.revokeOtherSessions();
撤销所有 Session:
await authClient.revokeSessions();
修改密码时:
await authClient.changePassword({
currentPassword,
newPassword,
revokeOtherSessions: true,
});
Better Auth 原生提供 Session 列表、单 Session 撤销、其他 Session 撤销以及全部 Session 撤销。
不要向前端日志或分析平台发送 Session Token。
25. Rate Limit
Better Auth 默认内存限流不适合 Serverless 多实例,因此 Cloudflare Workers 应使用数据库或外部一致性存储:
rateLimit: {
enabled: true,
storage: "database",
}
使用 Drizzle 时,需重新生成 Better Auth Schema,再生成和应用 Migration。
建议重点限制:
/sign-up/email
/sign-in/email
/request-password-reset
/send-verification-email
/change-password
/delete-user
业务接口还应增加独立限流,例如:
创建 API Key
批量创建链接
邀请组织成员
数据导出
发送邮件
高流量时,可将严格原子限流迁移到 Durable Objects;不要用普通 Workers KV 实现要求强一致性的计数器。
26. Hono RPC 与认证
Hono RPC 客户端需要携带 Cookie。
// src/client/lib/api-client.ts
import { hc } from "hono/client";
import type { AppType } from "../../../worker";
export const api = hc<AppType>("/", {
init: {
credentials: "include",
},
});
Hono RPC 可以从服务端路由推导请求参数和 JSON 响应类型。
受保护接口:
const profileRoute = app.get(
"/api/v1/profile",
loadSession,
requireAuth,
(c) => {
return c.json({
data: {
user: c.get("user"),
},
});
},
);
export type AppType =
typeof profileRoute;
客户端:
const response =
await api.api.v1.profile.$get();
if (response.status === 401) {
window.location.href = "/login";
}
const result = await response.json();
Hono RPC 的 TypeScript 类型安全不能替代服务端权限校验。
27. 数据库索引
Better Auth 性能指南建议重点检查以下字段的索引:
user.email
account.userId
session.userId
session.token
verification.identifier
Organization 等插件还会需要:
member.userId
member.organizationId
organization.slug
invitation.email
invitation.organizationId
Better Auth 当前建议开发者检查并补充这些索引,因为 Schema 生成工具未必自动生成所有性能索引。
每次重新生成 auth.ts 后,应检查 Git Diff,防止自定义索引被意外覆盖。
28. 安全基线
生产环境至少满足以下要求。
Secret
- Secret 长度至少 32 字符。
- Secret 只放在 Workers Secrets。
- 建立 Secret 轮换流程。
- 不在日志中输出 Secret。
Cookie
- 生产环境使用 HTTPS。
- Cookie 使用 HttpOnly。
- Cookie 使用 Secure。
- 同域优先使用 SameSite=Lax。
- 不要随意扩大 Cookie Domain。
CSRF 与 Origin
不要启用:
advanced: {
disableCSRFCheck: true,
disableOriginCheck: true,
}
Better Auth 默认会通过 Origin 校验、Fetch Metadata 和非简单请求等机制防御 CSRF。
OAuth
- OAuth Token 开启数据库加密。
- Callback URL 精确匹配。
- 不允许任意 Callback URL。
- 不轻易允许不同邮箱账号自动合并。
- Provider Secret 只存储在 Worker Secret。
密码
- 使用默认 scrypt。
- 最小长度建议 10~12。
- 重置密码后撤销其他 Session。
- 不设置不合理的最大密码长度。
- 不把密码写入日志或错误追踪系统。
邮件
- 不暴露邮箱是否已经注册。
- 验证邮件和密码重置邮件设置有效期。
- Token 不写日志。
- 对发送接口限流。
- 邮件链接只允许受信任域名。
业务授权
所有数据查询必须包含租户或用户归属:
where(
and(
eq(resource.id, id),
eq(resource.userId, currentUser.id),
),
)
不能先按 ID 查询,再完全依赖前端传入的 userId。
29. 测试方案
29.1 单元测试
覆盖:
认证配置生成
邮箱模板转义
权限判断
错误映射
受信任 Origin
OAuth Provider 条件配置
29.2 Worker 集成测试
覆盖:
GET /api/auth/get-session
POST /api/auth/sign-up/email
POST /api/auth/sign-in/email
POST /api/auth/sign-out
未登录访问受保护 API 返回 401
已登录访问受保护 API 成功
过期 Session 被拒绝
已撤销 Session 被拒绝
29.3 E2E
使用 Playwright:
注册
发送验证邮件
模拟点击验证链接
登录
刷新页面后 Session 保留
退出
忘记密码
重置密码
旧密码登录失败
新密码登录成功
Google OAuth 回调
GitHub OAuth 回调
Turnstile 测试应使用 Cloudflare 提供的测试 Sitekey 和 Secret,不要在自动化测试中调用真实生产配置。
29.4 安全测试
至少覆盖:
无 Origin 请求
伪造 Origin
缺少 Captcha
重复使用 Captcha Token
过期 Captcha Token
重复注册邮箱
错误密码暴力尝试
任意 Callback URL
跨用户资源访问
Session 撤销后敏感操作
30. 本地运行
生成 Schema 和 Migration:
pnpm auth:schema
pnpm db:generate
pnpm db:migrate:local
启动:
pnpm dev
Cloudflare Vite Plugin 会在本地运行 Worker,并提供本地 D1 Binding 模拟;本地 D1 与生产 D1 默认相互隔离。
检查接口:
curl http://localhost:5173/health
不要使用普通 curl 直接完成完整 Cookie 登录测试,除非通过 Cookie Jar 保存 Set-Cookie:
curl \
-c cookies.txt \
-b cookies.txt \
http://localhost:5173/api/auth/get-session
31. 部署流程
31.1 部署前
pnpm lint
pnpm typecheck
pnpm test
pnpm build
31.2 应用 Migration
pnpm db:migrate:remote
31.3 部署 Worker
pnpm deploy
31.4 部署后冒烟测试
检查:
/health
注册
验证邮件
登录
get-session
退出
忘记密码
OAuth Callback
受保护 API
31.5 回滚
Worker 代码可以通过 Cloudflare Deployment Version 回滚,但数据库 Schema 不应依赖简单的反向迁移。
推荐:
新增字段
→ 部署兼容代码
→ 数据回填
→ 切换读取逻辑
→ 删除旧字段
不要在一次部署中直接删除仍被旧代码使用的字段。
32. 常见问题排查
问题一:登录成功但浏览器没有 Cookie
检查:
前端是否使用 credentials: include
服务端 CORS 是否 credentials: true
CORS origin 是否精确匹配
Cookie 是否被浏览器阻止
是否错误设置 SameSite
BETTER_AUTH_URL 是否正确
是否使用 HTTPS
问题二:OAuth 报 redirect_uri_mismatch
检查:
BETTER_AUTH_URL
basePath
Google/GitHub Callback URL
localhost 端口
生产域名
http 与 https
末尾路径
标准 Callback:
https://example.com/api/auth/callback/google
https://example.com/api/auth/callback/github
问题三:数据库提示找不到表
执行:
pnpm auth:schema
pnpm db:generate
pnpm db:migrate:local
生产环境:
pnpm db:migrate:remote
问题四:增加插件后接口存在,但数据库报错
插件新增了数据库表或字段,但没有重新生成 Schema:
pnpm auth:schema
pnpm db:generate
pnpm db:migrate:remote
问题五:Captcha 一直失败
检查:
是否发送 x-captcha-response
Token 是否超过 5 分钟
Token 是否已经使用过
Sitekey 与 Secret 是否属于同一 Widget
本地是否使用测试 Key
是否提交后复用了旧 Token
问题六:注销设备后仍可短暂访问
如果开启 Cookie Cache,其他设备可能在 maxAge 结束前继续使用缓存 Session。
解决方式:
缩短 cookieCache.maxAge
敏感接口使用 disableCookieCache
完全关闭 Cookie Cache
问题七:Better Auth CLI 无法读取 D1
D1 Binding 只存在于 Workers Runtime。不要让 CLI 直接依赖 env.DB,使用本指南中的独立 better-auth.config.ts 和本地 SQLite Drizzle 实例生成 Schema。
问题八:CLI 生成后自定义 Schema 被覆盖
将业务表和 Auth 表分开:
auth.ts CLI 生成
business.ts 手工维护
每次生成后检查:
git diff src/db/schema/auth.ts
33. 推荐分阶段启用插件
P0:基础认证
Email/Password
Email Verification
Password Reset
Cookie Session
Turnstile
Google OAuth
GitHub OAuth
P1:账号安全
登录设备管理
修改密码
修改邮箱
删除账号
Passkey
Two-Factor Authentication
P2:SaaS 能力
Admin
Organization
Team
Invitation
API Key
P3:企业能力
SSO
SCIM
OAuth Provider
企业域名验证
审计日志
每增加一个插件,都要同步处理:
服务端插件
客户端插件
数据库 Schema
Migration
React UI
权限检查
E2E 测试
账号恢复流程
34. 最终生产配置建议
{
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 10,
autoSignIn: false,
revokeSessionsOnPasswordReset: true,
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
expiresIn: 3600,
autoSignInAfterVerification: false,
},
session: {
expiresIn: 7 * 24 * 60 * 60,
updateAge: 24 * 60 * 60,
freshAge: 10 * 60,
cookieCache: {
enabled: true,
maxAge: 3 * 60,
strategy: "compact",
},
},
account: {
encryptOAuthTokens: true,
accountLinking: {
enabled: true,
allowDifferentEmails: false,
},
},
rateLimit: {
enabled: true,
storage: "database",
},
}
最终架构:
React
├── Auth UI
├── useSession
└── Better Auth Client
Hono
├── Better Auth Handler
├── Session Middleware
├── Authentication Guard
└── Business Authorization
Cloudflare
├── Workers
├── Static Assets
├── D1
├── Turnstile
├── Secrets
└── Observability
D1
├── User
├── Account
├── Session
├── Verification
├── Rate Limit
└── Business Data
核心原则:
- D1 是 Session 和用户数据真相源。
- Better Auth 是唯一认证所有者。
- Hono 负责服务端鉴权与授权。
- React Route Guard 不能替代服务端安全检查。
- 同域部署优先于跨域 Cookie。
- OAuth Token 开启加密存储。
- 敏感操作绕过 Cookie Cache。
- Turnstile Token 必须服务端验证。
- Drizzle 项目使用
generate,不直接使用 Better Authmigrate。 - 每次插件变化都重新生成 Schema 和 Migration。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。