「公式ドキュメントや技術記事通りに実装したはずなのに、なぜかスパム判定連発される…」フロントエンドはAstro + React + TypeScript、バックエンドはヘッドレスWordPress(Contact Form 7 REST API)の開発環境で、Cloudflare Turnstileを導入した際にハマった、見落としがちな「仕様の差」を共有します。※注意:この問題はNext.jsやVueなど、REST APIを利用する全ヘッドレス環境で共通して発生する可能性があります。
結論
世の中の技術記事の多くに書かれているCloudflare Turnstile標準の変数名 cf-turnstile-response では動きません。 Contact Form 7(以下、CF7)のREST APIエンドポイントへ送信する場合は独自の変数名_wpcf7_turnstile_response でした。
これだけで解決する話ですが、ここに辿り着くまでに試した「不要な試行錯誤」と、正しい実装コードをまとめました。
開発環境
※2026年3月時点の最新版での検証
フロントエンド:
- Astro(ver.5.13.5)
- React(ver.18.3.1)
- TypeScript(ver.5.5.3)
- Zod(ver.3.23.8)
バックエンド:
- WordPress(ver.6.9.4)(以下、WP)
- CF7(REST API経由)(ver.6.1.5)
スパム対策: Cloudflare Turnstile(以下、Turnstile) (CF7公式インテグレーション機能を使用)
Cloudflare Turnstileとは
Turnstileは、GoogleのreCAPTCHAの代替として注目されている、CF7が推奨する無料スパムボット対策サービスです。
エラーとの闘い
お問い合わせフォーム送信後「スパム判定」されてしまう
・ 状況:
お問い合わせフォームの画面にTurnstileウィジェットは正常に表示され、セキュリティチェックも通過する。しかし、フォーム送信ボタンを押すと「メッセージの送信に失敗しました(スパム判定)」というアラートが出る。
やってしまいがちな「不要な」実装リスト
以下の「必要のないこと」を試して時間を溶かさないようにしてください。
1.CF7のメールタブの「メール」本文に、[turnstile]タグの設置してしまう。
---
[turnstile]
[submit "送信"]
UIはReact側で作るため、タグを書く必要はありません(インテグレーション設定だけでOK)。
2.react-turnstileをインストールしてしまう。
✕ npm i react-turnstile
◯ npm i @marsidev/react-turnstile
後述する@marsidev/react-turnstileは、react-turnstileより広く使われていて、メンテナンスも活発なパッケージです。公式ドキュメントもこちらが中心です。
3.Cloudflare管理画面のTurnstileの「許可するドメイン」に、”https://” を含める。
ホスト名
✕ https://your-frontend.netlify.app
◯ your-frontend.netlify.app
“https:// ” は必要ありません。
4.Cloudflare管理画面のTurnstileの「許可するドメイン」に、バックエンド側のWPのドメインに変更してしまう。
ホスト名
✕ your-backend-wordpress.com
◯ your-frontend.netlify.app
フロントエンド側のドメインを登録してください。
5.Astro側でも設定が必要だと思い、astro.config.mjsに以下のコードを追記してしまう。
astro.config.mjs
---
import cloudflare from "@astrojs/cloudflare";
export default defineConfig ({
adapter: cloudflare()
});
Netlifyにデプロイする場合、Cloudflareのアダプターは不要です。
6.Google reCAPTCHAと同様に、スクリプトの読み込みが必要だとcontact/index.astroにコードを追記してしまう。
✕ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js">
@marsidev/react-turnstile が内部でスクリプトを自動で読み込んでいるので必要ありません。
解決の決め手は何だったのか?
最大のポイント:「フロントエンドが送るデータのラベル名」 でした。
CloudflareTurnstileが定める標準の変数名: "cf-turnstile-response"
フロントエンドで生成された検証用トークンは、この変数名のPOSTパラメーターとしてバックエンドのサーバー側(WP) に送信されます。
CF7 (v6.1以降) のTurnstileインテグレーション機能を利用してREST API (https://{your-backend-wordpress.com}/wp-json/contact-form-7/v1/contact-forms/{your-post_id}/feedback) にPOSTする場合、CF7のような大規模なWPプラグインは、標準の変数名: “cf-turnstile-response”ではなく、他のプラグインとの名前の衝突を避けるため、意図的に独自のプレフィックス( _wpcf7_ )を付けた変数名にしていました。
CF7が期待する変数名: "_wpcf7_turnstile_response"
実装例:
formData.append("_wpcf7_turnstile_response", turnstileToken);
通常のWPテーマ環境では、CF7のJavaScriptが変換処理を自動実行しているので、この変数名の違いを意識する必要はありません。ヘッドレスWP環境では、”cf-turnstile-response”をそのまま送信すると、CF7側がTurnstile未検証と判断してスパム扱いになってしまいます。
React側の状態管理の注意点
Turnstileの検証用トークンは、1回限りの使い捨てで、時間制限もあります。そのため、以下の処理を忘れると、入力エラー等で再送信しようとした際に必ずスパム判定されてしまいます。
・ エラー時(バリデーション失敗やタイムアウト時)にウィジェットを reset() する。
・ 有効期限切れ(約5分)を考慮し、onExpire イベントで検証用トークンを破棄・再取得する。
実装手順(Astro + React + TypeScript)
1.Cloudflare Turnstile
アカウント作成。ドメイン登録。サイトキー、シークレットキーの取得。
2. 環境変数
①. env (ローカル開発) / .env.netlify (フロントエンド側Netlifyファイル) / .env.wordpress (バックエンド側WPファイル)に PUBLIC_TURNSTILE_SITE_KEY などを設定する。
PUBLIC_TURNSTILE_SITE_KEY=your-site-key-here
PUBLIC_CF7_API_URL=https://your-backend-wordpress.com/wp-json/contact-form-7/v1/contact-forms/{your-post_id}/feedback
PUBLIC_WPCF7_UNIT_TAG=wpcf7-f1234-p5678-o1
PUBLIC_WPCF7_CONTAINER_POST=5678
②. Netlify管理画面のProject configuration -> Environment variables -> Add a variablesボタンから環境変数を登録する。
3. 型定義
src/env.d.ts に PUBLIC_TURNSTILE_SITE_KEY を追加する。
interface ImportMetaEnv {
readonly PUBLIC_TURNSTILE_SITE_KEY?: string;
}
4. パッケージ
@marsidev/react-turnstile をインストール。
npm i @marsidev/react-turnstile
5. コード実装例(Astro / React / TypeScript)
import { useState, useRef } from "react";
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
// 環境変数から読み込む(.envファイルで管理)
const CF7_API_URL = import.meta.env.PUBLIC_CF7_API_URL as string;
const wpcf7UnitTag = import.meta.env.PUBLIC_WPCF7_UNIT_TAG as string;
const wpcf7ContainerPost = import.meta.env.PUBLIC_WPCF7_CONTAINER_POST as string;
type FormValues = {
name: string;
email: string;
message: string;
};
export default function ContactForm() {
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
const [turnstileLoadError, setTurnstileLoadError] = useState(false);
const turnstileRef = useRef<TurnstileInstance | null>(null);
const turnstileSiteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY as string;
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data:FormValues = {
name: (form.elements.namedItem("name") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
message: (form.elements.namedItem("message") as HTMLTextAreaElement).value,
};
// 検証用トークンの存在チェック
if (!turnstileToken) {
alert("セキュリティ確認が完了していません。");
return;
}
const formData = new FormData();
// ユーザー入力データ
formData.append("your-name", data.name);
formData.append("your-email", data.email);
formData.append("your-message", data.message);
// CF7のREST APIが必須とするメタデータ
// v5.8.7以降は必須。ない場合は400 Bad Requestになる。
formData.append("_wpcf7_unit_tag", wpcf7UnitTag);
// 任意。フォームが設置されているページのID。省略するとメールの[_post_title]等が空欄になる
formData.append("_wpcf7_container_post", wpcf7ContainerPost);
// 任意。省略するとデフォルトのlocaleになる。
formData.append("_wpcf7_locale", "ja");
// ★最重要:CF7 REST APIの独自の変数名でトークンを渡す
formData.append("_wpcf7_turnstile_response", turnstileToken);
try {
const response = await fetch(CF7_API_URL, {
method: "POST",
body: formData,
});
const result = await response.json();
if (result.status === "mail_sent") {
setTurnstileToken(null);
alert("送信に成功しました!");
window.location.replace("/thanks");
} else {
// エラーハンドリング(バリデーションエラーやスパム判定)
alert(result.message);// スパム判定時はここでエラーが表示される
throw new Error("Submission failed");
}
} catch (error) {
// ★重要:エラー時は必ずトークンをリセットし、ウィジェットを再起動させる
setTurnstileToken(null);
turnstileRef.current?.reset();
}
};
return (
<form onSubmit={(e) => void onSubmit(e)}>
{/* フォーム入力フィールド */}
{/* Turnstileウィジェット */}
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
onSuccess={(token) => {
setTurnstileToken(token);
setTurnstileLoadError(false);
}}
onExpire={() => {
setTurnstileToken(null);
turnstileRef.current?.reset();
}}
onError={() => {
setTurnstileToken(null);
setTurnstileLoadError(true);
}}
options={{
theme: "light",
size: "normal",
language: "ja",
}}
/>
{turnstileLoadError && <p style={{color: 'red'}}>セキュリティチェックの読み込みに失敗しました。</p>}
<button type="submit">送信する</button>
</form>
);
}
※ react-hook-formと組み合わせる場合は、handleSubmit でラップしてください。Turnstile側の実装は本記事のコードと同じです。
まとめ
検証用変数名を特定する
世の中のTurnstile導入関連の技術記事やCloudflare公式の検証ガイドは、標準の変数名”cf-turnstile-response” でコードが書かれていて、この変数名で検証用トークンが届くことを前提に解説されています。通常のWPテーマ環境やバックエンドを自作する場合などは”cf-turnstile-response” を使用します。
まず「そのプラグイン(CF7など)がネットワークログ(Networkタブ)でどの変数名で検証用トークンを投げているか」を確認することが必要です。CF7の場合は、”_wpcf7_turnstile_response”でした。
では、WPFormsやGravity Formsなど、その他のプラグインが期待している変数名はTurnstileが定める標準の”cf-turnstile-response”でしょうか?それとも独自のものでしょうか?開発者ツールで特定できます。

※ 画像はCF7の場合です
【具体的な確認手順】
- ブラウザでお問い合わせフォーム画面を開き、F12キー(または右クリック > 検証)で開発者ツールを開く。
- 上部メニューの [Network](ネットワーク) タブをクリックする。
- お問い合わせフォームの「送信」ボタンを押す。
- [Network](ネットワーク)画面の通信一覧(Name列)に現れるリクエスト(例:
feedbackなど)をクリックする。 - 右側に表示される詳細パネルの [Payload](ペイロード) タブを開く。
- 「Form Data」の中に含まれているTurnstileトークンの「キー名」が、現在送信している名前と一致しているか確認する。
認証フローを正しく理解する
Turnstileの認証フローを正しく理解していれば、早期のエラーの原因の発見に繋がりました。

「たった1行のラベル名の違い」に気づくまでに多くの時間を費やしましたが、これさえ押さえればTurnstileへの移行は一瞬です。この記事が、同じ悩みを持つエンジニアの助けになれば幸いです。最後までお読みいただきありがとうございました。