Turnstile Spin入門——AIエージェントに任せた認証防御をFirestoreで監査する
CloudflareのTurnstile Spinを題材に、AIエージェントでフォーム防御を導入しつつ、Next.js + FirestoreでSiteverify監査ログを残す実践手順を解説します。

はじめに
問い合わせフォームや無料トライアル登録を作るとき、こんな不安はありませんか?
| 場面 | よくある落とし穴 |
|---|---|
| フロントにCAPTCHA風のWidgetを置いた | バックエンドで検証しておらず、実は防御になっていない |
| AIコーディングエージェントに実装を頼んだ | 変更範囲は速いが、検証ログや運用確認が抜ける |
| bot対策を急いで入れた | 本番ホスト名、action、失敗理由を後から追えない |
2026年9月25日、Cloudflareは Turnstile Spin を発表しました。TurnstileのWidget作成、フォームへの埋め込み、バックエンドのSiteverify呼び出しまでを、Cloudflareダッシュボード、Wrangler CLI、またはClaude Code、Cursor、Codex、GitHub Copilot ChatなどのAIコーディングエージェントから進められる仕組みです。
ただし、AIエージェントがコードを入れてくれることと、チームが安全に運用できることは別問題です。本記事では、架空の予約管理SaaS「ReserveKit」を題材に、Next.js App Router + Firestore + TypeScript + GitHub Actionsで、Turnstile Spin後の検証結果を監査できる形に整えます。
参考にした一次情報は次の通りです。
- Agents can now set up your website’s security with Turnstile Spin
- Cloudflare Turnstile docs: Spin
- Cloudflare Turnstile docs: Validate the token
Turnstile Spinで何が変わるか
Cloudflareの公式記事では、Turnstileの基本実装は2段階だと説明されています。まずフロントエンドでWidgetを表示してトークンを発行し、次にバックエンドからSiteverify APIへPOSTして、そのトークンが正しいかを確認します。
Turnstile Spinは、この2段階をAIコーディングエージェントの作業に合わせて進めるための導線です。公式ドキュメントでは、次の3つの開始方法が示されています。
| 開始方法 | 向いている場面 |
|---|---|
| Cloudflareダッシュボード | 画面からWidgetを作り、生成されたプロンプトをエージェントへ渡したい |
| Wrangler CLI | ターミナルで wrangler turnstile widget create を使い、手元で設定したい |
| AIコーディングエージェント | プロジェクト内で設置箇所を探し、WidgetとSiteverifyを同じ流れで入れたい |
重要なのは、Spinが「Cloudflare側で勝手にアプリを書き換える」ものではないことです。公式説明では、アプリコードの変更は利用中のAIコーディングエージェントが手元のコードベースで行い、Cloudflareアカウント側に新しく作られる主なリソースはTurnstile Widgetです。
設計方針
ReserveKitでは、無料トライアル登録フォーム /trial を保護します。AIエージェントにはSpinの導入を頼みますが、チームの標準実装として次の3点を必須にします。
| 観点 | 実装ルール |
|---|---|
| トークン検証 | https://challenges.cloudflare.com/turnstile/v0/siteverify をサーバー側から呼ぶ |
| 判定条件 | success === true だけでなく、期待する action と hostname も確認する |
| 監査 | 成功・失敗・ホスト名・action・エラーコードをFirestoreへ残す |
Cloudflare公式ドキュメントでは、Turnstileトークンは最大2048文字、5分で期限切れ、単回利用と説明されています。そのため、リトライ時に同じトークンを使い回す設計は避けます。失敗したらWidgetをリセットし、ユーザーに再送信してもらう前提にします。
ハンズオン1: Widgetを作る
Spinを使う場合、まずCloudflareダッシュボードのTurnstile画面から「Set up with Spin」を開始できます。ターミナルで作りたい場合は、公式ドキュメントにあるWranglerコマンドを使います。
wrangler turnstile widget create "reservekit-trial" \
--domain reservekit.example \
--domain localhost \
--domain 127.0.0.1 \
--mode managed
本番では localhost や 127.0.0.1 を許可ホストとして扱わないようにします。ReserveKitでは、環境変数を次のように分けます。
NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAA...
TURNSTILE_SECRET=0x4AAAA...
TURNSTILE_HOSTNAMES=reservekit.example
NEXT_PUBLIC_TURNSTILE_SITE_KEY はブラウザへ出してよい公開値です。一方で TURNSTILE_SECRET はサーバー専用です。.env.local、GitHub ActionsのSecrets、ホスティング基盤のSecret Managerなどに置き、ログやチャットには出さない運用にします。
ハンズオン2: Next.jsフォームにWidgetを置く
まず、フォーム部品にTurnstileのスクリプトとWidgetを追加します。data-action は保護対象を識別するための値として使います。後段のSiteverify結果でも同じactionを確認します。
// src/components/TrialSignupForm.tsx
import Script from "next/script";
export function TrialSignupForm() {
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
if (!siteKey) {
throw new Error("NEXT_PUBLIC_TURNSTILE_SITE_KEY is not configured");
}
return (
<>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
/>
<form action="/api/trial-signups" method="POST">
<input name="companyName" type="text" required />
<input name="email" type="email" required />
<div
className="cf-turnstile"
data-sitekey={siteKey}
data-action="trial_signup"
/>
<button type="submit">無料トライアルを申し込む</button>
</form>
</>
);
}
AIエージェントに任せる場合も、レビューでは「Widgetが置かれたか」だけでなく、バックエンドで cf-turnstile-response を検証しているかを必ず確認します。フロントだけの実装は、Cloudflareのドキュメントでも保護として不十分だとされています。
ハンズオン3: SiteverifyをFirestore監査つきで実装する
次に、Route HandlerからSiteverifyを呼ぶ関数を作ります。成功時も失敗時もFirestoreへ記録することで、あとから「どのフォームで検証が動いているか」を追えるようにします。
// src/lib/turnstile.ts
import { FieldValue, getFirestore } from "firebase-admin/firestore";
type TurnstileVerifyResponse = {
success: boolean;
challenge_ts?: string;
hostname?: string;
action?: string;
cdata?: string;
"error-codes"?: string[];
};
type VerifyInput = {
token: string;
remoteIp?: string;
action: "trial_signup";
requestId: string;
};
export async function verifyTurnstile(input: VerifyInput) {
const secret = process.env.TURNSTILE_SECRET;
const expectedHostnames = new Set(
(process.env.TURNSTILE_HOSTNAMES ?? "")
.split(",")
.map((hostname) => hostname.trim())
.filter(Boolean),
);
if (!secret || expectedHostnames.size === 0 || input.token.length > 2048) {
return { ok: false, reason: "configuration_or_token_error" as const };
}
const response = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
secret,
response: input.token,
remoteip: input.remoteIp ?? "",
}),
},
);
const result = (await response.json()) as TurnstileVerifyResponse;
const ok =
result.success === true &&
result.action === input.action &&
typeof result.hostname === "string" &&
expectedHostnames.has(result.hostname);
await getFirestore().collection("turnstileAuditEvents").add({
requestId: input.requestId,
action: input.action,
hostname: result.hostname ?? null,
success: result.success,
accepted: ok,
errorCodes: result["error-codes"] ?? [],
createdAt: FieldValue.serverTimestamp(),
});
return {
ok,
reason: ok ? "accepted" : "siteverify_rejected",
} as const;
}
Route Handlerでは、検証に通った場合だけ本来の登録処理へ進めます。
// src/app/api/trial-signups/route.ts
import { randomUUID } from "node:crypto";
import { getApps, initializeApp } from "firebase-admin/app";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { NextRequest, NextResponse } from "next/server";
import { verifyTurnstile } from "@/lib/turnstile";
if (!getApps().length) {
initializeApp();
}
export async function POST(request: NextRequest) {
const form = await request.formData();
const token = form.get("cf-turnstile-response");
if (typeof token !== "string" || token.length === 0) {
return NextResponse.json({ error: "verification required" }, { status: 403 });
}
const verification = await verifyTurnstile({
token,
action: "trial_signup",
requestId: randomUUID(),
remoteIp: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(),
});
if (!verification.ok) {
return NextResponse.json({ error: "verification failed" }, { status: 403 });
}
await getFirestore().collection("trialSignups").add({
companyName: String(form.get("companyName") ?? ""),
email: String(form.get("email") ?? ""),
createdAt: FieldValue.serverTimestamp(),
});
return NextResponse.redirect(new URL("/trial/thanks", request.url), 303);
}
この実装では、AIエージェントが作った変更を人間がレビューしやすくなります。turnstileAuditEvents を見れば、Widgetだけ置かれてSiteverifyが呼ばれていない状態、ホスト名が本番値と合っていない状態、失敗が急増している状態を切り分けられます。
ハンズオン4: GitHub Actionsで未検証を点検する
最後に、監査ログを毎朝点検する入口を作ります。ここでは社内API /api/admin/turnstile-audit が、直近24時間の turnstileAuditEvents を集計してSlackやGitHub Issueへ通知する前提にします。
name: turnstile-audit
on:
schedule:
- cron: "0 22 * * *"
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- name: Check Turnstile verification health
run: |
curl -fsS -X POST "$AUDIT_ENDPOINT" \
-H "Authorization: Bearer $AUDIT_TOKEN"
env:
AUDIT_ENDPOINT: ${{ secrets.TURNSTILE_AUDIT_ENDPOINT }}
AUDIT_TOKEN: ${{ secrets.TURNSTILE_AUDIT_TOKEN }}
UTC 22時は日本時間の翌朝7時です。ReserveKitでは、次の条件を通知対象にします。
| 条件 | 通知理由 |
|---|---|
| 成功ログが0件 | フォームが使われていないか、Siteverifyが呼ばれていない |
accepted: false が急増 | bot増加、期限切れ、action不一致、hostname不一致を疑う |
hostname が許可リスト外 | 開発用ホストや想定外ドメインの混入を疑う |
AIエージェントにセキュリティ実装を頼むほど、最後に見るべきものは「差分がそれっぽいか」ではなく「本番で検証が継続しているか」です。Firestoreに小さな監査ログを置くだけで、レビュー、障害調査、運用改善がかなり楽になります。
まとめ
Turnstile Spinは、AIコーディングエージェント時代のセキュリティ導入をかなり現実的にしてくれます。Widget作成、設置箇所の発見、Siteverifyの追加を一連の流れにできるため、フォーム防御の初速は上がります。
一方で、Webアプリ側では次の3点を標準化しておくのがおすすめです。
- フロントのWidgetだけで完了にせず、必ずサーバー側でSiteverifyを呼ぶ
success、action、hostnameをまとめて確認する- 検証結果をFirestoreへ残し、GitHub Actionsで継続監査する
AIエージェントに任せる領域が増えるほど、チーム側には「任せたあとに確かめる仕組み」が必要です。Turnstile Spinは導入の入口として使い、Next.jsとFirestoreで運用の目を足す。この組み合わせが、実務では一番扱いやすい落としどころです。