Better Auth + D1 で認証を組む — CVE-2025-61928 の教訓とともに

Cloudflare Workers + D1 上に Better Auth を載せる最小構成と、2025 年に公開された CVE-2025-61928 (CVSS v4.0 9.3 / Critical、API Keys プラグインの未認証権限昇格) の要点、そして個人開発で同じ事故を踏まないための運用ルールをまとめます。

個人開発でも、ある程度ユーザー機能を載せたいタイミングで必ず認証が必要になります。自前で書くにはセキュリティ面の穴が多すぎ、Firebase Auth / Auth0 は SaaS 依存で個人の構成にはフィットしづらい。このギャップを埋めるのが OSS の Better Auth です。本記事では、Cloudflare Workers + D1 上に Better Auth を載せる最小構成と、2025-10 に公開された CVE-2025-61928 (CVSS v4.0 9.3 / Critical) の要点、そして同じ事故を踏まないための運用ルールをまとめます。

到達点

  • Cloudflare Workers + D1 の構成で email/password + Google OAuth が動く
  • Hono ミドルウェアでセッション検証、保護 API を作る
  • Better Auth v1.6 系を使用(API Keys プラグインを有効化するなら最低 v1.3.26 以上 を厳守)

なぜ Better Auth なのか

  • Drizzle アダプタが公式提供(better-auth/adapters/drizzle)で、D1 を含む SQLite 系と素直に接続できる
  • セッションは既定で Cookie ベースの DB セッション。JWT プラグインで外部サービス連携にも切替可能
  • Google / GitHub / Facebook 等の OAuth が組込み
  • Cloudflare D1 専用の公式統合ページは本稿執筆時点で未整備ですが、Hono 公式の Better Auth on Cloudflare 例(Neon Postgres ベース。D1 への移植が必要)と、コミュニティの better-auth-cloudflare(D1 構成)に実装例が蓄積しています

CVE-2025-61928 の要点

タイムライン:

要点:

  • 対象: better-authAPI Keys プラグイン(apiKey() を有効化した環境のみ)
  • 影響バージョン: < 1.3.26
  • 修正版: 1.3.26 以上(現行最新は 1.6.6 系)
  • CVSS: NVD では v4.0 で 9.3 (Critical)、GHSA では v3.1 で 8.6 (High)。高リスクである点は一致
  • CWE: CWE-285 (Improper Authorization) / CWE-306 (Missing Authentication for Critical Function)
  • 攻撃内容: 未認証の攻撃者が POST /api/auth/api-key/create 系にリクエストボディで任意の userId を指定することで、被害者名義の API キーを生成・改変できる。セッションが無い場合に、リクエストボディの値がそのまま認証コンテキストとして採用される権限判定バイパス。結果としてアカウント乗っ取りに至ります。

対応チェックリスト

  1. better-auth1.3.26 以上(推奨: 最新の 1.6 系)にアップデート
  2. API Keys プラグインを使っていなければ無効のままにしておく
  3. 稼働中システムは 既存 API キーの一斉ローテーション と脆弱期間のアクセスログ監査
  4. 依存は Renovate / Dependabot で常時追従。個人開発でも「アップデートを放置しない」だけでこのクラスの事故は防げる

実装 — Drizzle + D1 + Better Auth の最小構成

1. 依存と Wrangler 設定

bun add better-auth drizzle-orm
bun add -d @better-auth/cli drizzle-kit wrangler @cloudflare/workers-types

wrangler.jsonc では D1 バインディングを宣言し、Cookie 署名と OAuth 認証に使う値は vars ではなく Secrets に分離します。

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "r43lab-auth-minimal",
  "main": "src/index.ts",
  "compatibility_date": "2026-04-01",
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "auth",
      "database_id": "<paste id from `wrangler d1 create auth`>",
      "migrations_dir": "migrations"
    }
  ],
  "observability": { "enabled": true }
}

Secrets は wrangler secret put BETTER_AUTH_SECRET / GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET で登録。ローカル開発では .dev.vars に同じキーを書けば wrangler dev が自動で読み込みます。

2. Better Auth のセットアップ

スキーマは Better Auth CLI が生成します。プロジェクト直下で次を実行。

npx @better-auth/cli generate --output src/schema.ts
npx drizzle-kit generate
npx wrangler d1 migrations apply auth --local
npx wrangler d1 migrations apply auth --remote

これで user / session / account / verification の 4 テーブルが生成されます。

3. src/auth.ts

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";

type AuthEnv = {
  DB: D1Database;
  BETTER_AUTH_URL: string;
  BETTER_AUTH_SECRET: string;
  GOOGLE_CLIENT_ID: string;
  GOOGLE_CLIENT_SECRET: string;
};

export const createAuth = (env: AuthEnv) =>
  betterAuth({
    database: drizzleAdapter(drizzle(env.DB, { schema }), {
      provider: "sqlite",
      schema,
    }),
    baseURL: env.BETTER_AUTH_URL,
    secret: env.BETTER_AUTH_SECRET,
    emailAndPassword: { enabled: true },
    socialProviders: {
      google: {
        clientId: env.GOOGLE_CLIENT_ID,
        clientSecret: env.GOOGLE_CLIENT_SECRET,
      },
    },
    // API Keys プラグインは必要になるまで有効化しない。
    // 有効化する場合は必ず better-auth 1.3.26+ を使うこと (CVE-2025-61928 対策)。
    // plugins: [apiKey()],
  });

Workers では env がリクエスト時にしか取れないため、auth インスタンスは リクエスト毎に生成 します。Drizzle / Better Auth の生成コスト自体は軽量なので、Workers ランタイム上では問題になりません。

4. Hono ルーティングと保護ミドルウェア

// src/index.ts
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { createAuth } from "./auth";

type Bindings = Parameters<typeof createAuth>[0];
type Variables = { userId: string };

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();

// Better Auth の全ルート (/api/auth/*) を委譲
app.all("/api/auth/*", (c) => createAuth(c.env).handler(c.req.raw));

// 保護ルートのセッション検証
app.use("/api/me", async (c, next) => {
  const session = await createAuth(c.env).api.getSession({
    headers: c.req.raw.headers,
  });
  if (!session) throw new HTTPException(401, { message: "unauthorized" });
  c.set("userId", session.user.id);
  await next();
});

app.get("/api/me", (c) => c.json({ userId: c.get("userId") }));

export default app;

auth.handler(request) は Better Auth の全エンドポイント(sign-in / sign-up / callback / sign-out 等)を一括で処理します。保護 API は auth.api.getSession({ headers }) でセッション検証してから本処理に進む二段構成にすれば充分です。

運用面の落とし穴

  • ^1.3.26 にしても lockfile が古ければ 1.3.25 が入るpackage.json に書いただけで安心せず、bun update better-auth を明示的に回し、bun outdated を CI に入れて定期確認する
  • API Keys プラグインを plugins: [] に残したまま本番デプロイ。dev で試した [apiKey()] を remove し忘れて本番に穴が出るパターンは実害に直結します。PR レビュー時に plugins: 配列の差分は必ず確認
  • baseURL をハードコードしてしまう。プレビュー環境と本番で OAuth のコールバック URL が変わるため、環境変数 / Secret で差し替え前提にする
  • ローカル D1 と本番 D1 のスキーマ不一致wrangler d1 migrations apply--local--remote が別 DB。本番適用漏れで「ログインしようとすると 500」になるケースは頻発する
  • Cookie の Secure / SameSite 属性。本番で HTTPS 専用になっているか、クロスオリジン利用があるなら SameSite を明示する。Better Auth の advanced.cookies で上書き可能

まとめ

Better Auth は Cloudflare D1 と組み合わせても「認証フローを全部書く」必要がない水準まで成熟しています。一方で CVE-2025-61928 のように、有効化しているプラグインに未認証権限昇格の穴が潜む可能性は常にあります。アップデートを追うAPI Keys プラグインは必要になるまで有効化しない — この 2 つだけで、今回クラスの事故は回避できます。

次回は Cloudflare Durable Objects の Hibernation API 実践 を書く予定です。

参考

記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。

$cat./src/index.tsts
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { createAuth, type AuthEnv } from "./auth";

type Bindings = AuthEnv;
type Variables = { userId: string };

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();

// Better Auth が提供する全ルート (sign-in / sign-up / callback / sign-out など) を委譲
app.all("/api/auth/*", (c) => createAuth(c.env).handler(c.req.raw));

// 保護ルート用のセッション検証ミドルウェア
app.use("/api/me", async (c, next) => {
  const session = await createAuth(c.env).api.getSession({
    headers: c.req.raw.headers,
  });
  if (!session) throw new HTTPException(401, { message: "unauthorized" });
  c.set("userId", session.user.id);
  await next();
});

app.get("/api/me", (c) => c.json({ userId: c.get("userId") }));

app.get("/", (c) => c.text("r43lab-auth-minimal is running\n"));

app.onError((err, c) => {
  if (err instanceof HTTPException) return err.getResponse();
  console.error(err);
  return c.json({ code: "internal", message: "unexpected error" }, 500);
});

export default app;
slugbetter-auth-d1-cve-2025-61928files8click a file in the tree to switch