お問い合わせフォームのスパムを止める2 分で読めます
HTML フォームに Cloudflare Turnstile を追加する方法
プレーンな HTML フォームに Cloudflare Turnstile を追加します。ウィジェット、サーバー側の siteverify、テスト用キー、自前のキーやサーバーなしで動かす方法まで。
Turnstile のスクリプトと cf-turnstile 要素をフォームの中に追加すると、ウィジェットが非表示の cf-turnstile-response フィールドにトークンを入れます。そのトークンとシークレットキーをサーバーから Cloudflare の siteverify エンドポイントに送り、success が返ったときだけ送信を受け付けます。サーバーがなければフォームバックエンドに検証させるか、キーがまったく要らない Ikiform のマネージドチェックを使います。
Turnstile ならではの違い
Turnstile は 2 つの部分からなりますが、プレーンな HTML フォームには 1 つ分の場所しかありません。ウィジェットは公開の site key を使ってブラウザーで動き、トークンを生成します。チェックは secret key を使ってサーバーで動き、このキーは決してページに出してはいけません。ウィジェットを表示するだけでトークンを検証しないフォームは飾りです。ボットはエンドポイントに直接送信し、ウィジェットを完全に飛ばします。
つまり HTML フォームで本当に問題になるのは、シークレットをどこに置くかです。このページでは 3 つの答えを扱います。自前の小さなサーバー、キーを預かるフォームバックエンド、そしてバックエンド以外は誰もキーを持たないマネージドチェックです。
- Turnstile のウィジェットは、公開の site key を使ってブラウザーで動きます。ほとんどの訪問者は、それが何かしている様子を目にしません。
- トークンを発行し、フォームの中の非表示の cf-turnstile-response 入力欄に入れます。
- フォームが送信され、トークンも他のフィールドと一緒に送られます。
- サーバーが、シークレットキー、トークン、訪問者の IP を Cloudflare の siteverify エンドポイントに送ります。
- 応答が success であること、ホスト名と action が想定どおりであることを確認します。
- すべて一致すれば送信を受け付けます。どこかがおかしければ拒否します。
ウィジェットを追加する
Cloudflare のダッシュボードの Turnstile でウィジェットを作成し、フォームを置くホスト名を登録して、サイトキーをコピーします。次に、スクリプトと cf-turnstile 要素をフォームの中に追加します。要素は必ず <form> の中に置いてください。外にあると hidden のトークンフィールドもフォームの外にでき、送信されません。
<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 に転送します。転送先は、メッセージを保存したりメールしたりする別の仕組みに替えても構いません。
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 が付けるクエリ文字列を読みます:
<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_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 になります。
<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 のアカウントは不要です。両方の流れはスパムのドキュメントにあります。
Cloudflare に接続できない場合でも、Ikiform は送信を捨てません。理由 captcha_unavailable を付けてスパムとして保存するので、ダッシュボードから復元できます。自分のサーバーでも同じ判断を意図して行うべきです。上の Worker のように拒否する側に倒すか、確認用に送信を残すかです。
テストキー
Cloudflare は、localhost を含むどのホスト名でも使えるダミーのキーを公開しています。開発中はそれを使い、後で本物のキーに差し替えてください。
| キー | 値 | 動作 |
|---|---|---|
| サイトキー | 1x00000000000000000000AA | 常に通過、ウィジェットは表示 |
| シークレットキー | 1x0000000000000000000000000000000AA | siteverify が常に成功を返す |
| シークレットキー | 2x0000000000000000000000000000000AA | siteverify が常に失敗を返す |
テスト用のトークンには本物のホスト名が入っていないので、Worker のホスト名チェックで拒否されます。このチェックを緩めるのは開発環境だけにし、本番では決して緩めないでください。
動作を確認する
- 普通にフォームを送信します。送信は受け付けられ、ウィジェットは何も表示しないか、チェックボックスを 1 つ表示するだけです。
cf-turnstile要素を削除して送信します。送信は拒否されます。Worker が?error=check付きでリダイレクトするか、Ikiform が403 captcha_failedを返します。- 送信してから戻るボタンを押し、同じページでもう一度送信します。再利用したトークンは失敗するので、サーバーが本当に検証していることがわかります。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを持つ新しい送信データがあります。
- 何も届きませんか?ダッシュボードのステータスフィルターを 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 と同じように、先に自分でトークンを検証してください。
よくある質問
ウィジェットはどこでも動きますが、トークンはサーバー上でシークレットキーを使って検証する必要があります。サーバーがないなら、検証してくれるフォームバックエンドにフォームを送信するか、キーが不要な Ikiform のマネージドの確認を使ってください。
Cloudflare はウィジェット 20 個まで、それぞれホスト名 10 個までを無料で提供しており、料金ページにはチャレンジ数無制限と書かれています。頼りにする前に、Cloudflare の最新の料金ページを確認してください。
トークンが 300 秒より古いか、すでに 1 回検証されています。送信を試みるたびに、ウィジェットを新しく描画するかリセットしてください。
いいえ。フォームの Spam check を Ikiform にすれば、Ikiform が ikiform.com で Turnstile を実行します。アカウントが必要なのは、自分のキーと自分のウィジェットを使う場合だけです。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com