$ cat ./blog/better-auth-d1-cve-2025-61928.md
Better Auth + D1 で認証を組む — CVE-2025-61928 の教訓とともに Cloudflare Workers + D1 上に Better Auth を載せる最小構成と、2025 年に公開された CVE-2025-61928 (CVSS v4.0 9.3 / Critical、API Keys プラグインの未認証権限昇格) の要点、そして個人開発で同じ事故を踏まないための運用ルールをまとめます。
author r43
date 2026-04-24 (2026年4月24日)
reading 4 min
commit 1e884cb 個人開発でも、ある程度ユーザー機能を載せたいタイミングで必ず認証が必要になります。自前で書くにはセキュリティ面の穴が多すぎ、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-auth の API 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 キーを生成・改変できる 。セッションが無い場合に、リクエストボディの値がそのまま認証コンテキストとして採用される権限判定バイパス。結果としてアカウント乗っ取り に至ります。
対応チェックリスト
better-auth を 1.3.26 以上 (推奨: 最新の 1.6 系)にアップデート
API Keys プラグインを使っていなければ無効のまま にしておく
稼働中システムは 既存 API キーの一斉ローテーション と脆弱期間のアクセスログ監査
依存は 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 実践 を書く予定です。
参考
$ tree ./repo/
記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。
┌─ better-auth-d1-cve-2025-61928 ────────── files 08 ────────────────────── ● ● ● ─┐ $ cat ./src/index.ts ts
# ローカル `wrangler dev` 用の環境変数 / secrets。
# このファイルを `.dev.vars` にコピーして、値を埋めてください。
# `.dev.vars` は .gitignore で除外済み前提です。
# Cookie 署名に使う強いランダム文字列。
# 生成例: `openssl rand -base64 48` / `bunx nanoid 64`
BETTER_AUTH_SECRET=
# Better Auth がコールバック URL を組み立てる際の baseURL。
# dev: http://localhost:8787
# prod: https://auth.example.com
BETTER_AUTH_URL=http://localhost:8787
# Google OAuth の Client ID / Secret。
# https://console.cloud.google.com/apis/credentials で作成。
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= # r43lab-auth-minimal
Cloudflare Workers + D1 上で動く、Better Auth の最小構成。
[ 記事本文 ]( https://r43lab.com/blog/better-auth-d1-cve-2025-61928 ) と対になるコピペ用リポジトリです。
## Quick start
```bash
bun install
# D1 を作成 → ID を wrangler.jsonc に貼る
npx wrangler d1 create auth
# Better Auth CLI でスキーマ生成
npx @better-auth/cli generate --output src/schema.ts
# drizzle-kit でマイグレーション生成 → local / remote に適用
npx drizzle-kit generate
npx wrangler d1 migrations apply auth --local
npx wrangler d1 migrations apply auth --remote
# Secrets 登録
wrangler secret put BETTER_AUTH_SECRET # 任意の強いランダム文字列
wrangler secret put BETTER_AUTH_URL # 例: https://auth.example.com
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
# ローカル dev は .dev.vars に同じキーを書く (.dev.vars.example 参照)
cp .dev.vars.example .dev.vars
bun run dev
```
## 重要: CVE-2025-61928
`better-auth` の API Keys プラグイン ( `apiKey()` ) には、 **1.3.25 以下で未認証攻撃者が任意ユーザーの API キーを生成できる脆弱性 (CVSS v4.0 9.3)** があります。本テンプレートは `1.3.26` 以上(本稿執筆時点の最新は `1.6.6` )を要求し、API Keys プラグインは **無効の状態で配置** しています。
参考:
- [ GHSA-99h5-pjcv-gr6v ]( https://github.com/better-auth/better-auth/security/advisories/GHSA-99h5-pjcv-gr6v )
- [ CVE-2025-61928 (NVD) ]( https://nvd.nist.gov/vuln/detail/CVE-2025-61928 )
## 依存バージョンについて
`package.json` は **記事執筆時点 (2026-04) のスナップショット** です。認証ライブラリは特にアップデート追従が重要なので、clone 後に必ず走らせてください。
```bash
bun outdated
bun update
```
## Stack
Hono v4 · Better Auth v1.6 · Drizzle ORM · Wrangler v4 · Cloudflare D1 import { defineConfig } from "drizzle-kit" ;
export default defineConfig ({
schema: "./src/schema.ts" ,
out: "./migrations" ,
dialect: "sqlite" ,
}); {
"name" : "r43lab-auth-minimal" ,
"private" : true ,
"type" : "module" ,
"scripts" : {
"dev" : "wrangler dev" ,
"deploy" : "wrangler deploy" ,
"auth:gen" : "better-auth generate --output src/schema.ts" ,
"db:gen" : "drizzle-kit generate" ,
"db:local" : "wrangler d1 migrations apply auth --local" ,
"db:remote" : "wrangler d1 migrations apply auth --remote" ,
"tail" : "wrangler tail --format=pretty"
},
"dependencies" : {
"better-auth" : "^1.6.6" ,
"drizzle-orm" : "^0.45.2" ,
"hono" : "^4.12.14"
},
"devDependencies" : {
"@better-auth/cli" : "^1.4.21" ,
"@cloudflare/workers-types" : "^4.20260420.1" ,
"drizzle-kit" : "^0.31.10" ,
"typescript" : "^6.0.3" ,
"wrangler" : "^4.84.0"
}
} import { betterAuth } from "better-auth" ;
import { drizzleAdapter } from "better-auth/adapters/drizzle" ;
import { drizzle } from "drizzle-orm/d1" ;
import * as schema from "./schema" ;
// Cloudflare Workers では env がリクエスト時にしか取れないため、
// auth インスタンスはリクエスト毎に生成する。
// Drizzle / Better Auth の生成コストは軽量で、Workers ランタイム上で問題にならない。
export 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 ,
// email verification を有効にする場合は、メール送信 (Resend / MailChannels) との
// 連携をここで指定する。本テンプレートでは off のまま。
requireEmailVerification: false ,
},
socialProviders: {
google: {
clientId: env. GOOGLE_CLIENT_ID ,
clientSecret: env. GOOGLE_CLIENT_SECRET ,
},
},
// ── API Keys プラグインについて ──
// CVE-2025-61928 は API Keys プラグイン (apiKey()) を有効化した better-auth < 1.3.26 の
// 未認証権限昇格脆弱性 (CVSS v4.0 9.3)。有効化する場合は必ず better-auth 1.3.26+
// (推奨: 最新の 1.6 系) を使い、セッション検証がリクエストボディに引きずられない
// ことを https://github.com/better-auth/better-auth/security/advisories/GHSA-99h5-pjcv-gr6v
// を参照しつつ確認する。
// plugins: [apiKey()], // ← opt-in: 必要になってから有効化
}); 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; // NOTE: このファイルは `npx @better-auth/cli generate --output src/schema.ts` の
// 出力を想定したサンプルです。実際の生成結果は Better Auth のバージョンにより
// 差分が出るため、本番環境では必ず自分のプロジェクトで CLI を実行してください。
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" ;
export const user = sqliteTable ( "user" , {
id: text ( "id" ). primaryKey (),
name: text ( "name" ). notNull (),
email: text ( "email" ). notNull (). unique (),
emailVerified: integer ( "email_verified" , { mode: "boolean" }). notNull (),
image: text ( "image" ),
createdAt: integer ( "created_at" , { mode: "timestamp" }). notNull (),
updatedAt: integer ( "updated_at" , { mode: "timestamp" }). notNull (),
});
export const session = sqliteTable ( "session" , {
id: text ( "id" ). primaryKey (),
userId: text ( "user_id" )
. notNull ()
. references (() => user.id, { onDelete: "cascade" }),
expiresAt: integer ( "expires_at" , { mode: "timestamp" }). notNull (),
token: text ( "token" ). notNull (). unique (),
createdAt: integer ( "created_at" , { mode: "timestamp" }). notNull (),
updatedAt: integer ( "updated_at" , { mode: "timestamp" }). notNull (),
ipAddress: text ( "ip_address" ),
userAgent: text ( "user_agent" ),
});
export const account = sqliteTable ( "account" , {
id: text ( "id" ). primaryKey (),
userId: text ( "user_id" )
. notNull ()
. references (() => user.id, { onDelete: "cascade" }),
accountId: text ( "account_id" ). notNull (),
providerId: text ( "provider_id" ). notNull (),
accessToken: text ( "access_token" ),
refreshToken: text ( "refresh_token" ),
idToken: text ( "id_token" ),
accessTokenExpiresAt: integer ( "access_token_expires_at" , { mode: "timestamp" }),
refreshTokenExpiresAt: integer ( "refresh_token_expires_at" , { mode: "timestamp" }),
scope: text ( "scope" ),
password: text ( "password" ),
createdAt: integer ( "created_at" , { mode: "timestamp" }). notNull (),
updatedAt: integer ( "updated_at" , { mode: "timestamp" }). notNull (),
});
export const verification = sqliteTable ( "verification" , {
id: text ( "id" ). primaryKey (),
identifier: text ( "identifier" ). notNull (),
value: text ( "value" ). notNull (),
expiresAt: integer ( "expires_at" , { mode: "timestamp" }). notNull (),
createdAt: integer ( "created_at" , { mode: "timestamp" }),
updatedAt: integer ( "updated_at" , { mode: "timestamp" }),
}); {
"$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"
}
],
// BETTER_AUTH_SECRET / BETTER_AUTH_URL / GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
// は Secrets として `wrangler secret put <KEY>` で登録する。
// vars に書くと公開設定としてコードに残るので絶対に使わない。
"observability" : {
"enabled" : true
}
} slug better-auth-d1-cve-2025-61928│ files 8 click a file in the tree to switch