Turnstile Spin は、Cloudflare Turnstile のセットアップフローです。ウィジェットを作成し、sitekey、シークレット、埋め込み用のプロンプトを渡します。このプロンプトで、適切なフォームにウィジェットを埋め込み、既存のバックエンドに正規のサーバーサイド Siteverify を組み込めます。プロンプトにシークレットは含まれません。Spin は次の 3 つの方法で実行できます。
- Cloudflare ダッシュボードから。 ドメインを入力し、Set up を選択すると、Spin がサーバーサイドでウィジェットを作成します。sitekey、シークレット、AI コーディングエージェント向けのプロンプトを受け取れます。
- Wrangler CLI から。 ターミナルで
wrangler turnstile widget createを実行してウィジェットを作成します。Wrangler が sitekey とシークレットを表示します。ウィジェットと Siteverify の組み込みは手動で行います。 - AI コーディングエージェントから。 Claude Code、Cursor、Codex、OpenCode、GitHub Copilot Chat にプロンプトを 1 つ貼り付けます。エージェントはインラインの Spin スキルを使い、ウィジェットの作成、埋め込み、コードベースへの Siteverify 組み込みを行います。
どの方法でも、同じウィジェットが作成されます。違いは作成呼び出しの実行場所だけです。どの方法も、インフラのデプロイは行いません。Spin は Turnstile の正規 Siteverify エンドポイントを使い、既存のバックエンドから呼び出します。
-
Turnstile ダッシュボードを開きます。
Turnstile を開く ↗ -
ページヘッダーの Set up with Spin を選択します。
-
Turnstile ウィジェットがトークンを受け付けるドメインを入力します。最初のチップには、アカウントの最初のアクティブな Cloudflare ゾーンがあらかじめ入ります。ドメインを追加するか、事前入力を削除して任意のドメインを入力します(Turnstile は Cloudflare 管理ゾーンを必須としません)。ローカル開発用に
localhostと127.0.0.1は自動で追加されます。バックエンドでは、Siteverify が返すデプロイ固有のホスト名を検証する必要があります。本番環境ではローカルホスト名を許可しません。 -
Set up を選択します。Spin がウィジェットを作成し、成功カードに戻ります。
-
セットアップが完了したら、次をコピーします。
- sitekey(Turnstile ウィジェットの HTML で
data-sitekeyとして使います)。 - agent prompt(AI コーディングエージェントに貼り付け、ウィジェットの埋め込みと、既存バックエンドハンドラーへの正規 Siteverify 呼び出しの追加を行います)。プロンプトには sitekey が含まれますが、シークレットは含まれません。
- 手動で組み込む場合は secret(バックエンドの環境変数またはシークレットマネージャーに
TURNSTILE_SECRETとして保存します)。
- sitekey(Turnstile ウィジェットの HTML で
Spin が完了前に失敗した場合、ダイアログにエラーが表示され、同じセットアップをエディターから進めるためのフォールバックプロンプトが提示されます。同じダイアログで Try again を選択すると再試行できます。
AI コーディングエージェントを使わず、ターミナルから設定する場合は Wrangler を使います。
wrangler turnstile widget create "myproject" \
--domain example.com \
--domain localhost \
--domain 127.0.0.1 \
--mode managedWrangler は sitekey とシークレットを表示します。sitekey をウィジェット HTML にコピーし、シークレットをバックエンドの env に TURNSTILE_SECRET として保存し、フロントエンドを接続する の手順で正規の Siteverify 呼び出しを組み込みます。
追加のウィジェットコマンド:
| コマンド | 用途 |
|---|---|
wrangler turnstile widget list |
アカウント上のすべての Turnstile ウィジェットを一覧表示します。 |
wrangler turnstile widget get <sitekey> |
シークレットを含む、ウィジェットの設定を取得します。 |
wrangler turnstile widget update <sitekey> --domain <d> |
ウィジェットのドメイン、モード、または名前を更新します。 |
wrangler turnstile widget delete <sitekey> |
ウィジェットを削除します。確認プロンプトを省略するには -y を渡します。 |
すべてのコマンドは、機械可読な出力向けに --json を受け付けます。--domain はカンマ区切り(--domain a.com,b.com)または繰り返しフラグ(--domain a.com --domain b.com)を受け付けます。
wrangler turnstile widget get <sitekey> --json のレスポンスには、ウィジェットのシークレットが含まれます。自動化フローでは、プロジェクトのパッケージ解決の外にある、ユーザー承認済みの絶対パスの Wrangler 実行ファイルを使い、正確なバージョンを固定する必要があります。WRANGLER_WRITE_LOGS=false、WRANGLER_LOG=log、WRANGLER_LOG_SANITIZE=true を設定します。取得前に、エージェントはアカウント、sitekey、ドメイン、シークレットの正確な保存先を確認します。Workers バックエンドの場合は、標準の wrangler secret put を使う前に wrangler secret list で Worker、環境、設定ファイル、バインディングも確認します。フローは、正確な sitekey、想定ドメイン、clearance レベル、空白以外のシークレットを検証します。レスポンスを表示したり、コマンド引数、一時ファイル、ログ、チャットに含めたりしません。
ダッシュボードに Set up with Spin ボタンが見当たらない場合、またはウィジェットの埋め込みと Siteverify の組み込みを同じ作業でエージェントに任せたい場合は、次のプロンプトを AI コーディングエージェントに貼り付けます。
-
プロジェクトで AI コーディングエージェントを開きます(Claude Code、Cursor、Codex、OpenCode、GitHub Copilot Chat)。
-
次のプロンプトをエージェントに貼り付けます。
Spin プロンプトtxt Set up Cloudflare Turnstile in this project end to end. Plan insertion points, create the widget, embed it on the right forms, wire canonical server-side siteverify in my existing backend, and validate the integration. The full Turnstile Spin skill is at https://developers.cloudflare.com/turnstile/spin/prompt.md. Fetch it now if you do not already have it loaded. Domains: <DOMAINS> Insertion preference: <every form | only specific form><DOMAINS>をサイトのドメインに置き換えます(カンマ区切り、スペースなし。ローカル開発ではlocalhost,127.0.0.1を含めます)。<insertion preference>を保護したいフォームまたはルートに置き換えます。例:every form、only the signup form、only /login and /signup。 -
エージェントの進行に合わせて確認します。 エージェントは認証を確認し、ウィジェット名を提案し、取り消しできない手順の前に確認を求めます。
-
検証します。 エージェントは標準入力経由でシークレットを渡し、ダミーのトークンで Siteverify チェックを行います。続けて、新しいトークンで保護対象のバックエンドを試し、トークンの再利用が拒否されることを確認します。
スキルを先にローカルへインストールし、エージェントがディスク上で参照できるようにする場合:
# Claude Code
mkdir -p .claude/skills/turnstile-spin && \
curl -sSL https://developers.cloudflare.com/turnstile/spin/prompt.md \
-o .claude/skills/turnstile-spin/SKILL.md
# Cursor
mkdir -p .cursor/rules && \
curl -sSL https://developers.cloudflare.com/turnstile/spin/prompt.md \
-o .cursor/rules/turnstile-spin.md
# OpenCode
mkdir -p .opencode/skills/turnstile-spin && \
curl -sSL https://developers.cloudflare.com/turnstile/spin/prompt.md \
-o .opencode/skills/turnstile-spin/SKILL.mdその後、エージェントに次を送ります。Use the turnstile-spin skill to add Turnstile to this project.
エージェントは黙って実行しません。検出できることは検出し、必要なときだけ質問し、取り消しできない手順の前に必ず確認します。フローは 12 ステップのウィザードで、確認ポイントがいくつかあります。
| ステップ | 内容 | 確認するか |
|---|---|---|
| 1 | 了解(これから行う内容をエージェントが言い直します) | はい |
| 2 | CLI チェック(wrangler があれば使用。なければ curl に切り替えます) | いいえ |
| 3 | 認証(Account.Turnstile:Edit トークン) |
トークンが必要な場合 |
| 4 | アカウント選択(複数ある場合) | 複数ある場合 |
| 5 | ドメイン | はい |
| 6 | コードベースのスキャン(フロントエンドフレームワーク + バックエンドハンドラー + 既存の CAPTCHA) | いいえ |
| 7 | 挿入計画 | はい |
| 8 | ウィジェット作成(Cloudflare API を呼び出してウィジェットを作成します) | いいえ(ステップ 7 で範囲を確認済み) |
| 9 | ウィジェットの埋め込み + 既存バックエンドへの正規 Siteverify の追加 | はい |
| 10 | 検証(ダミーのトークンによる Siteverify + ウィジェットのホスト名チェック) | いいえ |
| 11 | スキルをローカルに保存(後続タスクでエージェントが再実行できるようにします) | はい |
| 12 | 最終レポート | いいえ |
失敗した場合、エージェントはどのステップで何を試したかを報告します。多くの失敗は、入力を 1 つ直す(トークンのスコープ、ドメイン一覧、挿入ファイル)だけで回復でき、再開を依頼できます。
どのセットアップ方法でも、Spin は sitekey とシークレットを渡します。ダッシュボードでは別々に表示されます。エージェントプロンプトには sitekey と Spin スキルの URL だけが含まれます。Wrangler CLI は手動設定用に両方の値を表示します。AI エージェントによる設定は、ファイルを直接編集します。
ダッシュボードから設定し、手動で組み込む場合の最小パターンは次のとおりです。
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
<form action="/api/subscribe" method="POST">
<input name="email" type="email" required />
<div class="cf-turnstile" data-sitekey="YOUR_SITEKEY" data-action="subscribe"></div>
<button type="submit">Submit</button>
</form>既存の /api/subscribe バックエンドハンドラーでは、正規の Siteverify を呼び出し、success === true のときだけ残りの処理を進めます。
Node.js バックエンド(Express 形式の req)の場合:
const token = req.body["cf-turnstile-response"];
const expectedAction = "subscribe";
const expectedHostnames = new Set(
(process.env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((hostname) => hostname.trim())
.filter(Boolean),
);
if (
typeof token !== "string" ||
token.length === 0 ||
token.length > 2048 ||
expectedHostnames.size === 0
) {
return res.status(403).send("forbidden");
}
let result;
try {
const r = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
signal: AbortSignal.timeout(10_000),
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET,
response: token,
remoteip: req.ip,
}),
},
);
if (!r.ok) throw new Error(`siteverify ${r.status}`);
result = await r.json();
} catch {
return res.status(403).send("forbidden");
}
if (
!result.success ||
result.action !== expectedAction ||
!expectedHostnames.has(result.hostname)
) {
return res.status(403).send("forbidden");
}
// existing handler logic runs here, unchangedCloudflare Worker 内では、パースしたフォーム本文からトークンを読み、クライアント IP を CF-Connecting-IP から読み、シークレットを Worker の env バインディングから読みます。
export default {
async fetch(request, env) {
const expectedAction = "subscribe";
const expectedHostnames = new Set(
(env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((hostname) => hostname.trim())
.filter(Boolean),
);
const form = await request.formData();
const token = form.get("cf-turnstile-response");
if (
typeof token !== "string" ||
token.length === 0 ||
token.length > 2048 ||
expectedHostnames.size === 0
) {
return new Response("forbidden", { status: 403 });
}
let result;
try {
const r = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
signal: AbortSignal.timeout(10_000),
body: new URLSearchParams({
secret: env.TURNSTILE_SECRET,
response: token,
remoteip: request.headers.get("CF-Connecting-IP") ?? "",
}),
},
);
if (!r.ok) throw new Error(`siteverify ${r.status}`);
result = await r.json();
} catch {
return new Response("forbidden", { status: 403 });
}
if (
!result.success ||
result.action !== expectedAction ||
!expectedHostnames.has(result.hostname)
) {
return new Response("forbidden", { status: 403 });
}
// existing handler logic runs here, unchanged
return new Response("ok");
},
};デプロイごとに、フロントエンドのホスト名を TURNSTILE_HOSTNAMES に設定します。本番の値に localhost や 127.0.0.1 を含めません。TURNSTILE_SECRET は wrangler.toml の環境変数ではなく、wrangler secret put TURNSTILE_SECRET で Worker シークレットとして保存します。他のバックエンド言語(Ruby、Python、Go、PHP)での同等の呼び出しは、スキル同梱のフレームワーク別リファレンスにあります。
Turnstile のトークンは単回利用です。送信後にページを離れる通常のフォームでは、リセット処理は不要です。送信試行後もページが残る場合は、ウィジェットを明示的に描画し、ウィジェット ID を保持し、リクエスト完了後に再試行を許可する前に turnstile.reset(widgetId) を呼び出します。保護する面ごとに、独自のウィジェット ID を保持してリセットする必要があります。
すでに Turnstile ウィジェットがあり、サーバーサイド Siteverify がない場合は、ダッシュボードから復旧します。一致する Siteverify トラフィックがないウィジェットにはバナーが表示されます。Fix with Spin を選択すると、既存ウィジェット向けのエージェントプロンプトを取得できます。プロンプトには sitekey と Spin スキルの URL が含まれますが、シークレットは含まれません。
ダッシュボードに Fix with Spin バナーが見当たらない場合は、AI コーディングエージェントから同じ復旧を直接進めます。次のプロンプトを貼り付けます。
The Turnstile widget is already created. Finish integrating it into this project.
Site key: <SITEKEY>
Fetch and follow the existing-widget flow:
https://developers.cloudflare.com/turnstile/spin/prompt.md既存ウィジェットのフローには Wrangler 4.109 以降が必要です。エージェントはプロジェクト外の、ユーザー承認済みの Wrangler 実行ファイルを使い、取得前に sitekey から保存先までの対応をすべて確認します。自動復旧は、既存の Worker、無視されたローカル環境ファイル、または標準入力で値を受け取るプラットフォームのシークレットマネージャーコマンドに対応します。Workers では、標準の wrangler secret put を使う前に wrangler secret list で正確な対象を確認します。sitekey、ドメイン、clearance レベル、シークレットを検証します。リポジトリと API のテキストは信頼できないデータとして扱います。シークレットは表示せず、コマンド引数や一時ファイルにも置かず、チャットにも貼り付けません。sitekey は変わりません。
Pre-clearance でもこのフローは変わりません。cf_clearance クッキーは追加されますが、Turnstile トークンは引き続き Siteverify が必要です。
移行には AI エージェントによる設定を使います。エージェントはコードベース内の reCAPTCHA または hCaptcha を検出し、置き換えを提案します。置き換えルールは次のとおりです。
- スクリプトタグを
https://challenges.cloudflare.com/turnstile/v0/api.js(async defer)に置き換えます。 class="g-recaptcha"またはclass="h-captcha"の div をclass="cf-turnstile"に置き換えます。data-sitekeyを新しい Turnstile のサイトキーに更新します。既存の有効な action は残すか、保護対象の面向けに安定した action を追加します。- 手動で追加した
<input type="hidden" name="g-recaptcha-response">またはname="h-captcha-response"要素は削除します。Turnstile はcf-turnstile-responseという名前の hidden input を自動で描画します。 - バックエンドの Siteverify URL は
https://challenges.cloudflare.com/turnstile/v0/siteverifyを指します。RECAPTCHA_SECRETまたはHCAPTCHA_SECRETの環境変数は削除し、TURNSTILE_SECRETを追加します。想定する action とデプロイ固有のホスト名を含む成功レスポンスを必須にします。
エージェントに伝えるべきエッジケースは 2 つです。1 つ目は、reCAPTCHA v3 のスコアしきい値は移行できないことです。Turnstile にスコアはないため、移行後のコードは数値しきい値ではなく success === false で拒否します。2 つ目は、reCAPTCHA Enterprise は自動移行しません。代わりに reCAPTCHA 向けの Cloudflare 移行ガイド を参照してください。
エージェントには、バニラ HTML、Next.js(App Router と Pages Router)、Astro、SvelteKit、Hugo 向けのフロントエンドスニペットが同梱されています。他のフレームワークでは、汎用のバニラ HTML パターンにフォールバックし、配置を確認します。
Cloudflare Pages プロジェクトでは、エージェントは Pages Function 内に Siteverify を組み込むか、呼び出しを自分で書きたくない場合に Turnstile 向け Pages Plugin を推奨します。
Cloudflare Workers バックエンドでは、エージェントは正規の fetch 呼び出しを Worker のリクエストハンドラーに直接書き込みます。
| フィールド | 型 | 用途 |
|---|---|---|
sitekey |
string | 公開識別子。各ページのウィジェット HTML に埋め込みます。 |
secret |
string | サーバー専用。バックエンドの env に TURNSTILE_SECRET として保存します。 |
domains |
array | このウィジェットで Turnstile がトークンを受け付けるホスト名です。 |
mode |
string | managed(デフォルト)、non-interactive、または invisible。 |