Skip to main content
技術

Cloudflare Workers で Better Auth のレートリミットが機能しない — memory ストレージと Isolate の罠

Cloudflare Workers の isolate はリクエストをまたいで状態を保持しない。Better Auth の rateLimit を memory で設定すると、カウンタが毎回リセットされて制限が機能しない。DB に永続化して解決するまでの話。

Cloudflare Workers で Better Auth のレートリミットが機能しない — memory ストレージと Isolate の罠

この記事でわかること

  • 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 カラムを要求する — スキーマ設計時の見落としポイント