Cloudflare ハンズオン教科書|2026-08 版
第 14 章

アプリを守る — Turnstile・WAF・レート制限

bot と攻撃からアプリを守る Cloudflare 流の定石

この章で学ぶこと

エッジで守るという発想

Cloudflare の定石はリクエストがアプリに到達する前に、エッジ(Cloudflare のネットワーク)で検査・遮断することです。攻撃リクエストが Worker やオリジンサーバーの CPU を消費する前に落とせます。守りは役割の異なる複数の層で構成されます。

WAF の役割は検出(detection)と緩和(mitigation)の 2 段階に分かれています。攻撃スコアや bot スコアなどの検出機能はリクエストに「点数を付けるだけ」で、それ自体はブロックしません。スコアを式で参照するカスタムルールやレート制限ルールを作って初めて緩和が実行されます。

ルールは種類ごとに決まった順序で評価され、Block や Managed Challenge のような終端アクション(terminating action)にマッチした時点で以降の評価は止まります。主要なものの実行順は次のとおりです。

  1. IP Access ルール
  2. カスタムルール
  3. レート制限ルール
  4. マネージドルール
リクエストは上から順に評価され、どの段でも打ち切られうる 終端アクション(Block 等)にマッチ 1. IP Access ルール 2. カスタムルール 3. レート制限ルール 4. マネージドルール エッジで遮断 Worker にもオリジンにも届かない CPU 時間もリクエスト数も使わない IP / ASN で拒否 自作の式にマッチ しきい値を超過 既知の攻撃シグネチャ 通過 通過 通過 すべて通過 Worker / オリジンへ到達
図 14-1 右へ抜ける出口は 4 段すべてに開いています。どこか一段でも終端アクションにマッチすればリクエストはそこで消え、Worker は起動しません(CPU 時間もリクエスト数も消費しない)。下へ抜けて Worker に届くのは、4 段すべてを通過したリクエストだけです。右向き矢印のラベルは、その層が主に何を捕まえるかを示しています。

適用単位にも重要な違いがあります。WAF・レート制限は「ゾーン」(Cloudflare に追加した独自ドメイン)単位の機能です。一方 Turnstile はゾーンに依存しない独立サービスで、Cloudflare を経由しないサイトや workers.dev 上のアプリにもそのまま使えます。ハンズオンが Turnstile 中心なのはこのためです(ゾーンの作り方は次章)。

Turnstile — CAPTCHA 代替の仕組み

Turnstile は「歪んだ文字を読む」タイプの従来型 CAPTCHA を置き換える、Cloudflare のスマート CAPTCHA 代替です。仕組みは 2 段構えで、ブラウザ側の JavaScript ウィジェットがチャレンジを実行してトークンを発行し、サーバー側がそのトークンを Siteverify API に送って正当性を確認します。

ウィジェットを作成すると、キーのペアが発行されます。

キー役割扱い
sitekeyページ側でウィジェットを呼び出す公開キーHTML に埋め込んでよい
secret keyサーバー側でトークンを検証する秘密キークライアントに絶対に出さない

ウィジェットには 3 つのモードがあります。

モード見え方特徴
Managed(推奨)必要時のみチェックボックスリスクに応じて対話の要否を Cloudflare が自動判断。読解問題は出ない
Non-Interactiveスピナー表示のみ訪問者は一切操作しない
Invisible完全に非表示操作も表示もなし。自サイトのプライバシーポリシーで Turnstile Privacy Addendum への言及が必要

ページへの設置は「implicit レンダリング」なら 2 行で済みます。スクリプトを読み込み、ウィジェットを出したい場所に cf-turnstile クラスの div を置くだけです。

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form action="/submit" method="POST">
  <input type="email" name="email" required />
  <div class="cf-turnstile" data-sitekey="<YOUR-SITE-KEY>"></div>
  <button type="submit">送信</button>
</form>

form 内に置くと、チャレンジ完了時に cf-turnstile-response という不可視 input が自動追加され、トークンが他のフィールドと一緒に送信されます。SPA など動的なページでは ?render=explicit を付けて turnstile.render() を自分で呼ぶ explicit レンダリングを使います。

Siteverify API によるサーバー側検証

サーバー側検証は必須です。ウィジェットの設置だけでは何も守れません。理由は 3 つあります。

検証は https://challenges.cloudflare.com/turnstile/v0/siteverify への POST で行います。リクエストは form-urlencoded と JSON の両方を受け付け、レスポンスは常に JSON です。

訪問者のブラウザ sitekey は公開情報 あなたの Worker secret key を保持 Turnstile API challenges.cloudflare.com ① ウィジェットがチャレンジを実行 ② トークンを発行(300 秒・1 回限り) ③ トークン同梱で POST ④ secret + token を siteverify へ ⑤ success: true / false ⑥ 判定 success が true のときだけ フォームを処理する
図 14-2 ①〜③ に登場する材料はすべて公開されています。sitekey は HTML に書いてあり、トークン欄には好きな文字列を入れて POST できるので、③ までは攻撃者にも真似できます。secret key を持つ Worker だけが引ける ④⑤ の往復を通って初めて、そのトークンが本物・未失効・未使用だと分かります。
パラメータ必須内容
secretYesウィジェットの secret key
responseYesクライアントから受け取ったトークン(最大 2,048 文字)
remoteipNo訪問者の IP アドレス(Worker では CF-Connecting-IP ヘッダーから取得)
idempotency_keyNoリトライを安全にするための UUID

レスポンスの success が true のときだけフォームを処理します。hostname(チャレンジが実行されたホスト名)や action(ウィジェットの data-action 値)が期待どおりかも確認すると、トークンの流用をより厳密に防げます。

開発時にはテスト専用キーが用意されており、実際のチャレンジを起こさずに成功・失敗・重複の各パターンを再現できます。テスト sitekey は localhost を含む任意のドメインで動作します。

キー種別挙動
1x00000000000000000000AAsitekey常に成功(可視)
2x00000000000000000000ABsitekey常に失敗(可視)
3x00000000000000000000FFsitekey強制的に対話チャレンジ
1x0000000000000000000000000000000AAsecret key検証が常に成功
2x0000000000000000000000000000000AAsecret key検証が常に失敗
3x0000000000000000000000000000000AAsecret key常に「使用済みトークン」エラー

Turnstile は無料プランで実用十分です。2026-08 時点で、無料枠はウィジェット 20 個まで・1 ウィジェットあたりホスト名 10 件まで・チャレンジ回数は無制限です(Enterprise はブランド非表示や Ephemeral ID などが追加)。

WAF — マネージドルール・カスタムルール・レート制限

WAF はゾーンに入ってくる Web・API リクエストを Rules language(式言語)で評価し、望ましくないトラフィックをフィルタリングします。構成要素は 3 本柱です。

マネージドルール — 既製の防御セット

Cloudflare が作成・毎週更新する既製ルールセットで、有効化するだけで既知の攻撃手法やゼロデイ脆弱性への防御が得られます。無料プランでは Free Managed Ruleset(影響の大きい脆弱性に絞ったサブセット)がデフォルトで適用済みです。Pro 以上では、より広範な Cloudflare Managed Ruleset と、スコア累積方式の OWASP Core Ruleset を有効化できます。OWASP はマッチした各ルールのスコアを合算し、しきい値超過でアクションを実行する方式で、誤検知(false positive)が出やすく、しきい値と paranoia level の調整が前提です。

カスタムルール — 自分の条件で守る

「この条件にマッチしたら Block / Managed Challenge / Skip」という自作ルールです。式には IP・国・パス・ヘッダー・ボディなどのリクエストプロパティを使えます。たとえば管理画面を特定 IP 以外から遮断するルールは次のような式になります。

(http.request.uri.path contains "/admin" and not ip.src in {203.0.113.7 198.51.100.0/24})

アクションに Block を選べば、この式にマッチしたリクエストはエッジで 403 相当の応答を受け、Worker にもオリジンにも届きません。

レート制限ルール — 量で守る

「同一 IP から 10 秒間に 10 回を超えて /login に POST が来たらブロック」のように、式 + 期間 + 回数 + 超過時のアクションで定義します。ほかに回数のカウント特性(characteristics。既定は IP)と、超過後にアクションを維持する時間(mitigation timeout)を指定します。カウンターはデータセンター単位で、反映に数秒の遅延があるため、「厳密に N 回まで許可」ではなく「異常な量を面で止める」装置と捉えるのが正しい理解です。

ログイン保護の定石は、閾値の異なる複数ルールの段階構成です。公式ベストプラクティスでは「1 分に 4 回失敗で Managed Challenge」「10 分に 10 回で Managed Challenge」「1 時間に 20 回で 1 日ブロック」という 3 本のルールを同時に並べる構成が紹介されています(3 本ともカウント対象は同じで、窓・しきい値・アクションだけが異なります)。Business 以上ではカウント式を分離でき、「401/403 を返したリクエストだけカウントする」応答ステータス連動も可能です。

同じ POST /login を、窓の違う 3 本のルールが同時に数える 同一 IP からの /login への POST しきい値 4 回 しきい値 10 回 しきい値 20 回 Managed Challenge Managed Challenge 1 日ブロック 1 分の窓 10 分の窓 1 時間の窓 超過 超過 超過 mitigation timeout の間だけ適用 カウンターはデータセンター単位で、反映に数秒の遅延がある 無料プランはルール 1 本・期間 10 秒のみ(2026-08 時点)
図 14-3 「3 段構え」は状態遷移ではありません。数える対象(左の 1 箱)は 3 本とも同じで、違うのは窓・しきい値・アクションだけの独立したルールが 3 本同時に走ります。窓が広いルールほど重いアクションを割り当てるのが定石で、どのアクションも mitigation timeout が過ぎれば自動で解けます。カウンターはデータセンター単位なので、「厳密に N 回まで許可」ではなく「異常な量を面で止める」装置として読んでください。

Bot 対策

Cloudflare はリクエストごとに bot スコア(1〜99。1 が「ほぼ確実に bot」、99 が「ほぼ確実に人間」)を算出します。無料プランはドメイン全体で検知 bot にチャレンジを出す Bot Fight Mode、Pro 以上はカテゴリ別にアクションを設定できる Super Bot Fight Mode、Enterprise は bot スコアそのものをカスタムルールの式で使える Bot Management です。細かいスコア値の利用は Enterprise 限定です。

主要機能のプラン別可否をまとめます(2026-08 時点)。

機能FreeProBusinessEnterprise
マネージドルールFree Managed Ruleset のみありありあり
カスタムルール数5201001,000
カスタムルールの正規表現不可不可可可
レート制限ルール数125100
レート制限の期間10 秒のみ最大 1 分最大 10 分最大 65,535 秒
Bot 対策Bot Fight ModeSuper Bot Fight ModeSuper Bot Fight ModeBot Management(bot スコア)

secrets の扱いと Secrets Store

Turnstile の secret key のような機微情報を、Worker ではどう保管すべきでしょうか。答えは secrets(暗号化されたテキスト値のバインディング)です。wrangler.jsonc の vars は平文でダッシュボードや Wrangler から見えてしまうため、機微情報を vars に書いてはいけません。

secrets は 2 系統あります。

vars — 値がそのまま残る wrangler.jsonc の vars Worker env.KEY で参照 バインド 平文で表示 ダッシュボード・Wrangler から値が見える secrets — 登録後は取り出せない wrangler secret put で登録 Worker env.KEY で参照 バインド 閲覧経路なし ダッシュボードでも 値は二度と読めない コードからの読み方は同じ — 違いは「後から値を見られるか」だけ
図 14-4 両者の違いは上半分ではなく下半分にあります。左は設定ファイルの値へ「読み出せる」辺が伸びているのに対し、右は同じ位置の辺が断ち切られている(✕)のが secrets の本体です。上半分(バインドして env.KEY で読む)は完全に同じなので、vars から secrets への移行コストはほぼゼロです。

ローカル開発では、プロジェクト直下の .dev.vars ファイル(dotenv 形式)の値が env に流れます。.dev.vars と .env は必ず .gitignore に追加してください。本番の登録は次のとおりです。

npx wrangler secret put TURNSTILE_SECRET_KEY
# プロンプトが出るので値を貼り付ける
# → 新しいバージョンが作成され、即座にデプロイされる

wrangler secret put はその場で新バージョンを作って即デプロイする点に注意してください(段階デプロイ中なら wrangler versions secret put を使います)。CI/CD ではコードと secrets を同時に上げる npx wrangler deploy --secrets-file .env.production も使えます。

Secrets Store を使う場合は、ストアに secret を作成してから wrangler.jsonc でバインドします。

{
  "secrets_store_secrets": [
    {
      "binding": "TURNSTILE_SECRET",
      "store_id": "<STORE_ID>",
      "secret_name": "turnstile-secret-key"
    }
  ]
}

<STORE_ID> はダッシュボードの Secrets Store タブか npx wrangler secrets-store store list で確認できる ID に置き換えます。Worker 単位の secrets と違い、値の取得は const key = await env.TURNSTILE_SECRET.get() と非同期呼び出しになります。この章のハンズオンは、どのプランでも使える Worker 単位の secrets で進めます。

ハンズオン 14-1|Turnstile 付きフォームと検証 Worker をテストキーで動かす

お問い合わせフォームを配信し、Siteverify API でトークンを検証する Worker を作ります。テストキーを使うので Turnstile ウィジェットの作成は不要、すべてローカルで完結します。所要 20 分。

このハンズオンは無料プランで完結します。テストキーは任意のドメイン(localhost 含む)で動作します。

  1. プロジェクトを作成する(テンプレートは Hello World / TypeScript を選択)
    npm create cloudflare@latest turnstile-form
    cd turnstile-form
  2. src/index.ts を次の内容に置き換える。まずフォームの HTML 定数から
    const PAGE = `<!doctype html>
    <html lang="ja">
    <head>
      <meta charset="utf-8" />
      <title>お問い合わせ</title>
      <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
    </head>
    <body>
      <form action="/submit" method="POST">
        <input type="email" name="email" placeholder="メールアドレス" required />
        <div class="cf-turnstile" data-sitekey="1x00000000000000000000AA"></div>
        <button type="submit">送信</button>
      </form>
    </body>
    </html>`;
  3. 続けて、同じファイルに fetch ハンドラーと検証関数を書く
    interface Env {
      TURNSTILE_SECRET_KEY: string;
    }
    
    interface SiteverifyResult {
      success: boolean;
      hostname?: string;
      "error-codes"?: string[];
    }
    
    async function verifyToken(token: string, remoteip: string, env: Env) {
      const res = await fetch(
        "https://challenges.cloudflare.com/turnstile/v0/siteverify",
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            secret: env.TURNSTILE_SECRET_KEY,
            response: token,
            remoteip,
          }),
        },
      );
      return (await res.json()) as SiteverifyResult;
    }
    
    export default {
      async fetch(request, env): Promise<Response> {
        const url = new URL(request.url);
        if (request.method === "GET" && url.pathname === "/") {
          return new Response(PAGE, {
            headers: { "Content-Type": "text/html; charset=utf-8" },
          });
        }
        if (request.method === "POST" && url.pathname === "/submit") {
          const form = await request.formData();
          const token = String(form.get("cf-turnstile-response") ?? "");
          const ip = request.headers.get("CF-Connecting-IP") ?? "";
          const result = await verifyToken(token, ip, env);
          if (!result.success) {
            const codes = (result["error-codes"] ?? []).join(", ");
            return new Response(`検証に失敗しました: ${codes}`, { status: 400 });
          }
          return new Response(`送信を受け付けました (hostname: ${result.hostname})`);
        }
        return new Response("Not Found", { status: 404 });
      },
    } satisfies ExportedHandler<Env>;
  4. プロジェクト直下に .dev.vars を作成し、テスト用 secret key を書く(このファイルを .gitignore に追加)
    TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"
  5. npx wrangler dev を起動し、ブラウザで http://localhost:8787 を開く。フォーム内にテストウィジェットが表示され、すぐに成功状態になる
  6. メールアドレスを入れて送信する。送信を受け付けました (hostname: ...) と表示されれば、トークンが Siteverify API で検証されている
  7. 失敗系も確認する。.dev.vars の値を 2x0000000000000000000000000000000AA(常に失敗する secret key)に変えて dev サーバーを再起動し、再送信すると 検証に失敗しました: invalid-input-response が返る

ウィジェットが発行したトークンがフォームの cf-turnstile-response フィールド経由で Worker に届き、サーバー側で検証されるまでの流れを一気通貫で確認できました。

ハンズオン 14-2|本番ウィジェットと secret で公開デプロイする

14-1 の Worker を、本物のウィジェットと wrangler secret を使って公開します。所要 15 分。

このハンズオンも無料プランで完結します(Turnstile 無料枠: ウィジェット 20 個、2026-08 時点)。

  1. Cloudflare ダッシュボードで Turnstile ページを開き、ウィジェットを作成する。名前は「Contact Form」など用途がわかるものにし、モードは Managed、ホスト名に turnstile-form.<YOUR_SUBDOMAIN>.workers.dev(自分の workers.dev サブドメイン)を追加する
  2. 発行された sitekey と secret key を控える。secret key はこの後 secrets に入れるのでコードに貼らない
  3. src/index.ts の data-sitekey をテストキーから本番 sitekey に置き換える
  4. デプロイする
    npx wrangler deploy
  5. 本番の secret key を登録する。プロンプトに値を貼り付けると、新バージョンが作られて即デプロイされる
    npx wrangler secret put TURNSTILE_SECRET_KEY
  6. https://turnstile-form.<YOUR_SUBDOMAIN>.workers.dev を開き、フォームを送信する。今度は本物のチャレンジが実行され、検証成功のメッセージが返る

ダッシュボードの Turnstile ページでチャレンジ数・トークン検証数のアナリティクスを確認できます(無料プランは過去 7 日分・2026-08 時点)。Siteverify を呼んでいないウィジェットは検証数が 0 のままなので、実装漏れの検知にも使えます。

つまずきポイント

テストキーと本番キーを混ぜると必ず失敗する — テスト sitekey が発行するのはダミートークン(XXXX.DUMMY.TOKEN.XXXX)で、本番 secret key はこれを拒否します。逆に、テスト secret key は本物のトークンを拒否します。sitekey と secret key は必ず「テスト同士」「本番同士」の組で使ってください。

timeout-or-duplicate エラーが出る — トークンは発行から 300 秒で失効し、1 回しか検証できません。フォームを開いたまま放置して送信した場合や、同じトークンを 2 回検証した場合に発生します。クライアント側で turnstile.reset() を呼んでウィジェットをリセットし、新しいトークンを取得させてください。

api.js を自分のサーバーから配信してはいけない — Turnstile のスクリプトは必ず challenges.cloudflare.com の正規 URL から読み込みます。プロキシやキャッシュ配信をすると Cloudflare 側の更新時に壊れます。またウィジェットは http:// / https:// のページ専用で、file:// で開いた HTML では動きません。CSP 利用時は challenges.cloudflare.com への接続許可が必要です。

secret を vars に書いてしまう / .dev.vars をコミットしてしまう — wrangler.jsonc の vars は平文で閲覧可能なので、機微情報は必ず wrangler secret put で登録します。ローカル用の .dev.vars と .env は .gitignore に追加してください。なお両方のファイルがあると .dev.vars だけが読まれ、.env は無視されます(併用不可)。

理解度チェック

Q1. Turnstile のウィジェットを設置するだけでは不十分で、Siteverify API の呼び出しが必須なのはなぜ?

トークンは偽造できるからです。攻撃者はチャレンジを完了せず任意の文字列をフォームエンドポイントへ直接 POST できるため、サーバー側で secret key を使って Siteverify API に問い合わせ、トークンが本物か・失効していないか・未使用かを確認して初めて防御が成立します。

Q2. Turnstile トークンの有効期間と再利用の可否は?

発行から 300 秒(5 分)で失効し、検証は 1 回限りです。期限切れ・使用済みトークンの検証は timeout-or-duplicate エラーになり、turnstile.reset() で新しいトークンを取得し直す必要があります。

Q3. WAF のカスタムルールとマネージドルール、先に評価されるのはどちら?

カスタムルールです。実行順は IP Access ルール → カスタムルール → レート制限ルール → マネージドルールで、先に Block などの終端アクションにマッチした時点で以降の評価は止まります。

Q4. 無料プランのレート制限ルールで設定できるルール数とカウント期間は?(2026-08 時点)

1 ルールのみ、カウント期間・ブロック継続時間ともに 10 秒固定です。カウント特性は IP、式に使えるフィールドは Path と Verified Bot に限られます。より長い期間や複数ルールには有料プランが必要です。

Q5. Turnstile の secret key を Worker で保管する場所として vars が不適切な理由と、正しい方法は?

vars は平文でダッシュボードや Wrangler から値が見えてしまうためです。npx wrangler secret put で secrets として登録すれば設定後は値を閲覧できません(コードからはどちらも env 経由で読めます)。複数の Worker での再利用にはアカウントレベルの Secrets Store を使います。

まとめと次章へ

この章では、エッジでアプリを守る Cloudflare の多層防御を学びました。

WAF やレート制限をフルに使うには、独自ドメインを Cloudflare のゾーンとして追加する必要があります。次章「CDN・DNS・カスタムドメイン」では、そのゾーンの作り方 — DNS 管理、プロキシ、キャッシュ、Worker へのカスタムドメイン割り当て — を学びます。