AI

Turnstile Spin入門——AIエージェントに任せた認証防御をFirestoreで監査する

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

2026年9月28日
CloudflareTurnstileAIエージェントNext.jsFirestore
Turnstile Spin入門——AIエージェントに任せた認証防御をFirestoreで監査する

はじめに

問い合わせフォームや無料トライアル登録を作るとき、こんな不安はありませんか?

場面よくある落とし穴
フロントに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後の検証結果を監査できる形に整えます。

参考にした一次情報は次の通りです。

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で運用の目を足す。この組み合わせが、実務では一番扱いやすい落としどころです。