# HTML フォームに Cloudflare Turnstile を追加する方法

> プレーンな HTML フォームに Cloudflare Turnstile を追加します。ウィジェット、サーバー側の siteverify、テスト用キー、自前のキーやサーバーなしで動かす方法まで。

公開日 2026-10-25 · https://ikiform.com/ja/guides/spam-protection/cloudflare-turnstile

Turnstile のスクリプトと `cf-turnstile` 要素をフォームの中に追加すると、ウィジェットが非表示の `cf-turnstile-response` フィールドにトークンを入れます。そのトークンとシークレットキーをサーバーから Cloudflare の siteverify エンドポイントに送り、`success` が返ったときだけ送信を受け付けます。サーバーがなければフォームバックエンドに検証させるか、キーがまったく要らない Ikiform のマネージドチェックを使います。

## Turnstile ならではの違い

Turnstile は 2 つの部分からなりますが、プレーンな HTML フォームには 1 つ分の場所しかありません。ウィジェットは公開の **site key** を使ってブラウザーで動き、トークンを生成します。チェックは **secret key** を使ってサーバーで動き、このキーは決してページに出してはいけません。ウィジェットを表示するだけでトークンを検証しないフォームは飾りです。ボットはエンドポイントに直接送信し、ウィジェットを完全に飛ばします。

つまり HTML フォームで本当に問題になるのは、シークレットをどこに置くかです。このページでは 3 つの答えを扱います。自前の小さなサーバー、キーを預かるフォームバックエンド、そしてバックエンド以外は誰もキーを持たないマネージドチェックです。

図: Turnstile トークンの一生: ウィジェットから判定まで

1. Turnstile のウィジェットは、公開の site key を使ってブラウザーで動きます。ほとんどの訪問者は、それが何かしている様子を目にしません。
2. トークンを発行し、フォームの中の非表示の cf-turnstile-response 入力欄に入れます。
3. フォームが送信され、トークンも他のフィールドと一緒に送られます。
4. サーバーが、シークレットキー、トークン、訪問者の IP を Cloudflare の siteverify エンドポイントに送ります。
5. 応答が success であること、ホスト名と action が想定どおりであることを確認します。
6. すべて一致すれば送信を受け付けます。どこかがおかしければ拒否します。

トークンは 300 秒有効で、検証できるのは 1 回だけです。同じトークンを 2 回確認すると timeout-or-duplicate で失敗します。

## ウィジェットを追加する

Cloudflare のダッシュボードの **Turnstile** でウィジェットを作成し、フォームを置くホスト名を登録して、サイトキーをコピーします。次に、スクリプトと `cf-turnstile` 要素をフォームの中に追加します。要素は必ず `<form>` の中に置いてください。外にあると hidden のトークンフィールドもフォームの外にでき、送信されません。

`contact.html`:

```html
<form action="https://example.com/api/contact" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

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

- `data-action="contact"` はウィジェットのラベルです。siteverify がこれを返すので、サーバーは別のフォーム用に発行されたトークンを拒否できます。
- スクリプトは Cloudflare 自身の URL から読み込む必要があります。Cloudflare によると、プロキシしたコピーやセルフホストしたコピーではウィジェットが動きません。
- サイトが Content Security Policy を送っている場合は、`script-src` と `frame-src` の両方で `https://challenges.cloudflare.com` を許可してください。
- Cloudflare は無料でウィジェットを 20 個まで、それぞれホスト名 10 個まで使えるので、ステージング環境のホストも登録しておきましょう。

## 自分のサーバーでトークンを検証する

トークンはどんなサーバーでも検証できます。シークレットとトークンを付けて siteverify に `POST` を 1 回送るだけです。ここでは、静的なフォームの送信先になる Cloudflare Worker として実装しています。トークン、ホスト名、action を確認し、トークンを取り除いてから、シークレットキーでフィールドを Ikiform に転送します。転送先は、メッセージを保存したりメールしたりする別の仕組みに替えても構いません。

`src/index.ts`:

```ts
interface Env {
  TURNSTILE_SECRET_KEY: string;
  IKIFORM_SECRET_KEY: string;
}

interface Verdict {
  success: boolean;
  hostname?: string;
  action?: string;
  "error-codes"?: string[];
}

const SITE = "https://example.com";
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
    const form = await request.formData();

    const check = await fetch(SITEVERIFY, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        secret: env.TURNSTILE_SECRET_KEY,
        response: String(form.get("cf-turnstile-response") ?? ""),
        remoteip: request.headers.get("CF-Connecting-IP"),
        idempotency_key: crypto.randomUUID(),
      }),
    });
    const verdict = (await check.json()) as Verdict;
    const expected = verdict.hostname === new URL(SITE).hostname && verdict.action === "contact";
    if (!verdict.success || !expected) {
      console.warn("turnstile", verdict["error-codes"] ?? [], verdict.hostname, verdict.action);
      return Response.redirect(`${SITE}/contact?error=check`, 303);
    }

    form.delete("cf-turnstile-response");
    const sent = await fetch(ENDPOINT, {
      method: "POST",
      headers: {
        Accept: "application/json",
        Authorization: `Bearer ${env.IKIFORM_SECRET_KEY}`,
        Origin: request.headers.get("Origin") ?? SITE,
      },
      body: form,
    });
    return Response.redirect(sent.ok ? `${SITE}/thanks` : `${SITE}/contact?error=send`, 303);
  },
};
```

- siteverify は JSON とフォームエンコードのどちらのボディも受け付け、常に JSON で応答します。`remoteip` は任意です。`idempotency_key` を使うと、ネットワークエラーの後でも安全に確認をリトライできます。
- `success` だけでなく `hostname` と `action` も確認してください。あなたのサイトキーを使って他人のページで発行されたトークンでも `success` になります。
- 2 つのシークレットはどちらも `wrangler secret put` で保存し、`wrangler.toml` には決して書かないでください。
- Worker は `303` リダイレクトで応答するので、プレーンなフォームに JavaScript は要りません。フォームの `action` を Worker のルートに向けます。
- ページの `Origin` ヘッダーを引き継いで渡します。サーバー側の fetch はこれを送らず、Ikiform は無料の `localhost` テストの判定にこれを使うからです。

確認に失敗したときにフォームのページでエラーを表示するには、Worker が付けるクエリ文字列を読みます:

`contact.html`:

```html
<p id="form-error" role="alert" hidden></p>
<script>
  const reason = new URLSearchParams(location.search).get("error");
  const banner = document.getElementById("form-error");
  if (reason && banner) {
    banner.textContent =
      reason === "check" ? "The human check didn't pass. Try again." : "Your message didn't send. Try again.";
    banner.hidden = false;
  }
</script>
```

> サーバー側の版はシークレットキーで送信するので、Ikiform は **Allowed domains** と自身の人間確認をスキップします。すべての送信がサーバーの IP から届くため、訪問者ごとに 1 分 5 件という Standard の制限は全訪問者の合計で数えられます。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

## Ikiform に検証を任せる

サーバーをまったく運用したくないなら、フォームから直接 Ikiform に送信し、siteverify の呼び出しを Ikiform に任せられます。方法はフォームごとの **Spam check** 設定で決まります。

| 方法 | 自分で管理するキー | ページ上のウィジェット | 自前のサーバー |
| --- | --- | --- | --- |
| 上で作った自分のサーバーまたは Worker | サイトキーとシークレットキー | あり（自分のもの） | はい |
| Ikiform で **Your own Turnstile keys** | サイトキーとシークレットキー（Ikiform に貼り付け） | あり（自分のもの） | なし |
| Ikiform で **Ikiform** | なし | なし。ikiform.com の確認ページか小さなフレーム | なし |

**Your own Turnstile keys** では、サイトキーとシークレットキーをフォームの設定に貼り付けます。シークレットは暗号化して保存されます。ウィジェットのマークアップはそのままで、フォームの送信先を Ikiform のエンドポイントにします。Ikiform が `cf-turnstile-response` を検証し、トークンのホスト名をフォームの **Allowed domains**（許可ドメイン）と照合します。有効なトークンがない送信は、シークレット API キーがあっても `403 captcha_failed` になります。

`contact.html`:

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
  <button>Send message</button>
</form>

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

**Ikiform** では、ページに何も追加しません。トークンなしのプレーンな HTML 送信には ikiform.com の短い **Checking you're human** ページが表示され、そこで Turnstile を実行してからフィールドを送ります。`fetch` による送信には代わりに `403 challenge_required` が返るので、先に `@ikiform/sdk` の `challenge()` を呼んでトークンを取得してください。Cloudflare のアカウントは不要です。両方の流れは[スパムのドキュメント](https://ikiform.com/ja/docs/spam.md#human-check)にあります。

Cloudflare に接続できない場合でも、Ikiform は送信を捨てません。理由 `captcha_unavailable` を付けてスパムとして保存するので、ダッシュボードから復元できます。自分のサーバーでも同じ判断を意図して行うべきです。上の Worker のように拒否する側に倒すか、確認用に送信を残すかです。

## テストキー

Cloudflare は、`localhost` を含むどのホスト名でも使えるダミーのキーを公開しています。開発中はそれを使い、後で本物のキーに差し替えてください。

| キー | 値 | 動作 |
| --- | --- | --- |
| サイトキー | `1x00000000000000000000AA` | 常に通過、ウィジェットは表示 |
| シークレットキー | `1x0000000000000000000000000000000AA` | siteverify が常に成功を返す |
| シークレットキー | `2x0000000000000000000000000000000AA` | siteverify が常に失敗を返す |

テスト用のトークンには本物のホスト名が入っていないので、Worker のホスト名チェックで拒否されます。このチェックを緩めるのは開発環境だけにし、本番では決して緩めないでください。

## 動作を確認する

1. 普通にフォームを送信します。送信は受け付けられ、ウィジェットは何も表示しないか、チェックボックスを 1 つ表示するだけです。
2. `cf-turnstile` 要素を削除して送信します。送信は拒否されます。Worker が `?error=check` 付きでリダイレクトするか、Ikiform が `403 captcha_failed` を返します。
3. 送信してから戻るボタンを押し、同じページでもう一度送信します。再利用したトークンは失敗するので、サーバーが本当に検証していることがわかります。
4. ダッシュボードで **Submissions** を開きます。送ったすべてのフィールドを持つ新しい送信データがあります。
5. 何も届きませんか？ダッシュボードのステータスフィルターを **Spam** に切り替えてください。スパムにも本物の送信データと同じ成功の応答が返ります。

## ハマりどころ

- **トークン 1 つにつき 1 回。** `fetch` で処理した送信が失敗したら、`turnstile.reset()` を呼んで新しいトークンを取得します。ページ全体を再読み込みすれば自動で取得されます。
- **入力に時間がかかる人。** トークンは 300 秒で期限切れになります。ウィジェットの `data-refresh-expired` 設定で、自動で新しいトークンを取るか、クリックを待つかが決まります。
- **www とルートドメイン。** siteverify にとって `www.example.com` と `example.com` は別のホスト名です。フォームが実際に動いている方と比較するか、両方を許可してください。
- **シングルページのナビゲーション。** 暗黙的なレンダリングのスクリプトは、読み込み時に 1 回だけウィジェットを描画します。クライアント側でルートが変わる場合は、`?render=explicit` と `turnstile.render()` で明示的に描画してください。
- **Ikiform 自身のキーを使ったサーバーからの送信。** サーバーがシークレットキーで Ikiform に転送すると、Ikiform はマネージドの確認をスキップします。Worker と同じように、先に自分でトークンを検証してください。

## よくある質問

### サーバーなしで Cloudflare Turnstile を使えますか？

ウィジェットはどこでも動きますが、トークンはサーバー上でシークレットキーを使って検証する必要があります。サーバーがないなら、検証してくれるフォームバックエンドにフォームを送信するか、キーが不要な Ikiform のマネージドの確認を使ってください。

### Cloudflare Turnstile は無料ですか？

Cloudflare はウィジェット 20 個まで、それぞれホスト名 10 個までを無料で提供しており、料金ページにはチャレンジ数無制限と書かれています。頼りにする前に、Cloudflare の最新の料金ページを確認してください。

### siteverify が timeout-or-duplicate を返すのはなぜですか？

トークンが 300 秒より古いか、すでに 1 回検証されています。送信を試みるたびに、ウィジェットを新しく描画するかリセットしてください。

### Ikiform で Turnstile を使うのに Cloudflare のアカウントは必要ですか？

いいえ。フォームの Spam check を Ikiform にすれば、Ikiform が ikiform.com で Turnstile を実行します。アカウントが必要なのは、自分のキーと自分のウィジェットを使う場合だけです。

## その他のガイド

- [お問い合わせフォームのスパムを止める](https://ikiform.com/ja/guides/spam-protection.md): 本物の訪問者に負担をかけない層でお問い合わせフォームのスパムを止めます。ハニーポット、時間、内容のスコア、レート制限、必要なときだけの人間確認です。
- [reCAPTCHA の代替](https://ikiform.com/ja/guides/spam-protection/recaptcha-alternatives.md): Turnstile や hCaptcha から Proof of Work、CAPTCHA なしまで、フォーム向け reCAPTCHA の代替を比較し、小さく安全な変更で乗り換えます。
- [静的サイト](https://ikiform.com/ja/guides/spam-protection/static-site.md): シークレットを保持できず送信数も数えられない静的サイトのフォームでも、ハニーポットのパーシャル、安全なスクリプト、サーバー側のチェックでスパムを防ぎます。
- [CAPTCHA なしで](https://ikiform.com/ja/guides/spam-protection/without-captcha.md): CAPTCHA をまったく使わずにフォームスパムの大半を防ぐ方法。ハニーポット、署名付きの表示時刻、内容のスコア、レート制限を単独ではなく合算して使います。
- [ハニーポットフィールド](https://ikiform.com/ja/guides/spam-protection/honeypot.md): スクリーンリーダー、キーボード、自動入力の邪魔をせずにボットを捕まえるハニーポットを、各スタックのサーバー側チェックとあわせてどんなフォームにも追加します。
- [レート制限](https://ikiform.com/ja/guides/spam-protection/rate-limiting.md): 共有ストアで訪問者ごとにフォーム送信のレート制限をかけ、Retry-After 付きの 429 を返し、1 つの IP を共有するオフィスを締め出さないようにします。
