この記事でわかること
- Cloudflare WorkersのIsolateモデルがメモリ状態を保持しない理由
- Better AuthのrateLimitを
memoryで設定すると機能しない仕組み - DBに永続化して解決する具体的な手順とスキーマ設計の注意点
背景
コーヒー抽出記録アプリ「MyCupLog」を Cloudflare Pages + Better Auth で開発しています。ブルートフォース攻撃対策として、ログイン試行回数のレートリミットを設定しました。Better Auth には rateLimit オプションがあり、設定自体は数行で完了します。
しかし「設定したはずのレートリミットがどうも効いていない」という状況に気づき、原因を調べると Cloudflare Workers の実行モデルに起因する問題でした。
ハマったポイント
何が起きていたか
Better Auth の rateLimit を以下のように設定しました。
// ❌ 問題のある設定
export function createAuth(env: Env) {
return betterAuth({
// ...
rateLimit: {
enabled: true,
window: 10,
max: 100,
storage: 'memory', // ← ここが問題
customRules: {
'/sign-in/email': {
window: 60,
max: 5,
},
},
},
})
}
storage: 'memory' は、ライブラリのデフォルト設定でもあり、一般的なNode.jsサーバー環境では問題なく動作します。しかし Cloudflare Pages Functions(Workers)では機能しません。
原因:Cloudflare Workers Isolateのライフサイクル
Cloudflare Workers はリクエストごとに V8 Isolate と呼ばれる軽量な実行コンテキスト上で動作します。このIsolateは以下の特性を持っています。
- グローバルなメモリの持続は保証されない — Isolateはリクエスト間で再利用されることもあるが、同じIsolateに次のリクエストが来る保証はない
- 同一リクエスト内では有効 — 1リクエストの処理中はメモリが使える
- スケールアウト時は別Isolateで処理される — 複数のワーカーインスタンスが並列に動く
つまり「リクエストAでログイン失敗カウントを+1してメモリに保存」しても、「リクエストBでそのカウントを読み出せる保証がありません」。異なるIsolateインスタンスが処理すればカウンタは共有されず、何度失敗しても制限に引っかかりません。
一般的なサーバー(Node.js / Rails など)では長期間プロセスが稼働してメモリを保持し続けるため、この問題は発生しません。サーバーレス・エッジランタイムに特有の落とし穴です。
解決策:ストレージをDBに永続化する
// ✅ 修正後
rateLimit: {
enabled: true,
window: 10,
max: 100,
storage: 'database', // メモリではなくDBに永続化
customRules: {
'/sign-in/email': {
window: 60,
max: 5,
},
},
},
storage: 'database' にすると、Better Auth はレートリミットのカウンタをDBテーブル(rateLimit)に書き込みます。Isolateが再生成されてもDBの値は残るため、カウントが正しく機能します。
スキーマ設計の追加ハマり
storage: 'database' に切り替えた後、マイグレーションSQLを書いて適用しましたが、起動時にエラーが発生しました。
-- ❌ 最初に書いたスキーマ(動かなかった)
CREATE TABLE IF NOT EXISTS "rateLimit" (
"key" TEXT NOT NULL PRIMARY KEY,
"count" INTEGER NOT NULL,
"lastRequest" INTEGER NOT NULL
);
Better Auth の adapter はすべてのテーブルに id カラムがあることを前提として動作します。id がないと adapter の内部処理でエラーになります。
-- ✅ 正しいスキーマ
CREATE TABLE IF NOT EXISTS "rateLimit" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"count" INTEGER NOT NULL,
"lastRequest" INTEGER NOT NULL,
PRIMARY KEY ("id"),
UNIQUE ("key")
);
key を PRIMARY KEY にせず、id を PRIMARY KEY にして key は UNIQUE 制約にする点がポイントです。
再発防止策
Better Auth の adapter 仕様として「全テーブルに id カラムが必要」という前提を rules に追記しました。
<!-- .claude/rules/auth.md に追記 -->
## Better Auth + database storage の注意
`storage: 'database'` 用テーブルには必ず `id TEXT NOT NULL PRIMARY KEY` を追加すること。
Better Auth adapter は全テーブルに id カラムを前提とするため、ない場合は実行時エラーになる。
実装
変更ファイルは2つだけで、修正自体はシンプルです。
1. auth.server.ts の1行変更
// storage: 'memory' → 'database' に変更するだけ
storage: 'database',
2. マイグレーションSQLの追加
-- src/lib/migrations/008_add_rate_limit_table.sql
CREATE TABLE IF NOT EXISTS "rateLimit" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"count" INTEGER NOT NULL,
"lastRequest" INTEGER NOT NULL,
PRIMARY KEY ("id"),
UNIQUE ("key")
);
マイグレーションを開発DB → 本番DBの順に適用して完了です。
Claude Codeはどこまで使ったか
任せた部分: マイグレーションSQLの初稿・auth.server.ts の変更
自分でやった部分: 「レートリミットが効いていない」という気づき。Cloudflare WorkersのIsolateモデルを調べて原因特定すること。
修正が必要だった部分: AIが生成したマイグレーションSQLには id カラムがなく、実行時エラーになりました。Better Auth のadapter仕様は公式ドキュメントには明示されておらず、ソースコードを読んで確認する必要がありました。
まとめ
- Cloudflare WorkersのIsolateはメモリの持続を保証しない — サーバーレス環境で
memoryストレージを使うカウンタ系機能は軒並み動かない - Better AuthのrateLimitは
storage: 'database'にする — Cloudflare Pages構成では必須の設定 - Better Auth adapterは全テーブルに
idカラムを要求する — スキーマ設計時の見落としポイント