Better Auth 完整接入指南:基于 React、Hono、Cloudflare Workers 与 D1 构建认证系统

深入讲解 Better Auth 在 React、Hono、Cloudflare Workers、D1 与 Drizzle ORM 技术栈中的完整接入流程,涵盖邮箱密码登录、Session、OAuth、邮箱验证、密码重置、Turnstile、数据库迁移、服务端鉴权、安全配置、测试与生产部署。

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 本身基于标准 RequestResponse,在 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("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

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,并把 usersession 放入 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 的 baseURLbasePath 一致。

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。
  • 生产环境使用 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. 常见问题排查

检查:

前端是否使用 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

核心原则:

  1. D1 是 Session 和用户数据真相源。
  2. Better Auth 是唯一认证所有者。
  3. Hono 负责服务端鉴权与授权。
  4. React Route Guard 不能替代服务端安全检查。
  5. 同域部署优先于跨域 Cookie。
  6. OAuth Token 开启加密存储。
  7. 敏感操作绕过 Cookie Cache。
  8. Turnstile Token 必须服务端验证。
  9. Drizzle 项目使用 generate,不直接使用 Better Auth migrate
  10. 每次插件变化都重新生成 Schema 和 Migration。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页