コンテンツにスキップする

Cloudflare Turnstileの導入方法について

Cloudflare Turnstileの導入方法について書きます。
過去に作業した際のメモをまとめ直してから記事にしていますので、抜けや漏れがあるかもしれません。

目次

サイトキーとシークレットキーの準備をする

今回はテスト用のサイトキーとシークレットキーを用います。
サイトキーには1x00000000000000000000AAを設定します。
シークレットキーには1x0000000000000000000000000000000AAを設定します。

本番環境の場合、Cloudflareの管理画面からCloudflare Turnstileのウィジェットを追加します。
それから、追加したウィジェットのサイトキーとシークレットキーを設定するようにします。

Testing · Cloudflare Turnstile docs

wrangler.jsoncに設定を記述する

wrangler.jsoncには、下記のような設定を記述します。
nameのフィールドなどは、環境によって異なりますので、好きな値を記述してください。

{
	"name": "cloudflare-turnstile-demo",
	"main": "src/index.js",
	"compatibility_date": "2025-09-01",
	"compatibility_flags": ["nodejs_compat"]
}

HTMLのファイルを作成する

implicit.htmlというファイルを作成して、下記のコードをコピーします。

<!DOCTYPE html>
<html lang="ja">

<head>
	<title>Cloudflare Turnstileのデモ(暗黙的)</title>
	<meta charset="UTF-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1.0" />

	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.1/css/bootstrap.min.css"
		integrity="sha512-siwe/oXMhSjGCwLn+scraPOWrJxHlUgMBMZXdPe2Tnk3I0x3ESCoLz7WZ5NTH6SZrywMY+PB1cjyqJ5jAluCOg=="
		crossorigin="anonymous" referrerpolicy="no-referrer" />

	<style>
		html,
		body {
			height: 100%;
		}

		body {
			display: flex;
			align-items: center;
			padding-top: 40px;
			padding-bottom: 40px;
			background-color: #fefefe;
		}

		.form-signin {
			width: 100%;
			max-width: 330px;
			padding: 15px;
			margin: auto;

			.checkbox {
				font-weight: 400;
			}

			.form-floating:focus-within {
				z-index: 2;
			}

			input[type="text"] {
				margin-bottom: -1px;
				border-bottom-right-radius: 0;
				border-bottom-left-radius: 0;
			}

			input[type="password"] {
				margin-bottom: 10px;
				border-top-left-radius: 0;
				border-top-right-radius: 0;
			}
		}
	</style>
</head>

<body>
	<main class="form-signin">
		<form method="POST" action="/">
			<h2 class="h3 mb-3 fw-normal">Cloudflare Turnstileのデモ(暗黙的)</h2>

			<div class="form-floating">
				<input type="text" id="user" class="form-control">
				<label for="user">ユーザー名</label>
			</div>
			<div class="form-floating">
				<input type="password" id="pass" class="form-control" autocomplete="off"
					value="CorrectHorseBatteryStaple">
				<label for="pass">パスワード</label>
			</div>

			<div class="checkbox mb-3">
				<div class="cf-turnstile" data-sitekey="1x00000000000000000000AA" data-theme="light"></div>
			</div>

			<button class="w-100 btn btn-lg btn-primary" type="submit">サインイン</button>
		</form>
	</main>

	<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" defer></script>
</body>

</html>

explicit.htmlというファイルを作成して、下記のコードをコピーします。

<!DOCTYPE html>
<html lang="ja">

<head>
	<title>Cloudflare Turnstileのデモ(明示的)</title>
	<meta charset="UTF-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1.0" />

	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.1/css/bootstrap.min.css"
		integrity="sha512-siwe/oXMhSjGCwLn+scraPOWrJxHlUgMBMZXdPe2Tnk3I0x3ESCoLz7WZ5NTH6SZrywMY+PB1cjyqJ5jAluCOg=="
		crossorigin="anonymous" referrerpolicy="no-referrer" />

	<style>
		html,
		body {
			height: 100%;
		}

		body {
			display: flex;
			align-items: center;
			padding-top: 40px;
			padding-bottom: 40px;
			background-color: #fefefe;
		}

		.form-signin {
			width: 100%;
			max-width: 330px;
			padding: 15px;
			margin: auto;

			.checkbox {
				font-weight: 400;
			}

			.form-floating:focus-within {
				z-index: 2;
			}

			input[type="text"] {
				margin-bottom: -1px;
				border-bottom-right-radius: 0;
				border-bottom-left-radius: 0;
			}

			input[type="password"] {
				margin-bottom: 10px;
				border-top-left-radius: 0;
				border-top-right-radius: 0;
			}
		}
	</style>
</head>

<body>
	<main class="form-signin">
		<form method="POST" action="/">
			<h2 class="h3 mb-3 fw-normal">Cloudflare Turnstileのデモ(明示的)</h2>

			<div class="form-floating">
				<input type="text" id="user" class="form-control">
				<label for="user">ユーザー名</label>
			</div>
			<div class="form-floating">
				<input type="password" id="pass" class="form-control" autocomplete="off"
					value="CorrectHorseBatteryStaple">
				<label for="pass">パスワード</label>
			</div>

			<div class="checkbox mb-3">
				<div id="myWidget"></div>
			</div>

			<button class="w-100 btn btn-lg btn-primary" type="submit">サインイン</button>
		</form>
	</main>

	<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=turnstileCallback" defer></script>

	<script>
		function turnstileCallback() {
			turnstile.render('#myWidget', {
				sitekey: '1x00000000000000000000AA',
			});
		}
	</script>
</body>

</html>

JavaScriptのファイルを作成する

index.jsというファイルを作成して、下記のコードをコピーします。

import explicitHtmlString from "./explicit.html";
import implicitHtmlString from "./implicit.html";

const getQueryParameters = (request) => {
	const { searchParams } = new URL(request?.url);
	const query = {};
	searchParams?.forEach?.((value, key) => {
		query[key] = value;
	});
	return query;
};

const getRequestByString = async (request) => {
	const contentType = request?.headers?.get?.("content-type") || "";
	if (contentType.includes("application/json")) {
		return JSON.stringify(await request.json());
	} else if (contentType.includes("application/text")) {
		return request.text();
	} else if (contentType.includes("text/html")) {
		return request.text();
	} else if (contentType.includes("text/plain")) {
		return request.text();
	} else if (contentType.includes("form")) {
		const formData = await request.formData();
		const body = {};
		for (const entry of formData.entries()) {
			body[entry[0]] = entry[1];
		}
		return JSON.stringify(body);
	}
	return undefined;
};

const getRequestByJson = async (request) => {
	const requestString = await getRequestByString(request);
	if (!requestString) {
		return undefined;
	}
	return JSON.parse(requestString);
};

export default {
	async fetch(request, env, ctx) {
		// Cloudflare Turnstileのシークレットキーです。
		const secretKey = "1x0000000000000000000000000000000AA";

		// GETリクエストの場合には、HTMLを表示します。
		if (request?.method === "GET") {
			const query = getQueryParameters(request);
			if (query?.type == "explicit") {
				return new Response(explicitHtmlString, {
					headers: { "Content-Type": "text/html" },
				});
			}

			return new Response(implicitHtmlString, {
				headers: { "Content-Type": "text/html" },
			});
		}

		// POSTリクエストのデータを取得して、cf-turnstile-responseの値の存在を確認します。
		const requestData = await getRequestByJson(request);
		if (!requestData || !requestData?.["cf-turnstile-response"]) {
			return new Response(
				JSON.stringify({
					status: 500,
					message: "Something went wrong, When get cf-turnstile-response",
				}),
				{
					status: 500,
					headers: { "Content-Type": "application/json" },
				}
			);
		}

		// Cloudflare Turnstileで認証の処理をおこないます。
		const formData = new FormData();
		formData.append("secret", secretKey);
		formData.append("response", requestData?.["cf-turnstile-response"]);
		formData.append("remoteip", request.headers.get("CF-Connecting-IP"));

		const turnstileObj = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
			method: "POST",
			body: formData,
		});

		const outcome = await turnstileObj.json();
		if (!outcome?.success) {
			return new Response(
				JSON.stringify({
					status: 500,
					message: "Something went wrong, When validate by turnstile",
					data: outcome?.["error-codes"],
				}),
				{
					status: 500,
					headers: { "Content-Type": "application/json" },
				}
			);
		}

		return new Response(
			JSON.stringify({
				status: 200,
				message: "OK",
				data: outcome,
			}),
			{
				status: 200,
				headers: { "Content-Type": "application/json" },
			}
		);
	},
};

Cloudflare Turnstileで認証をする

下記のようにwranglerのコマンドを実行して、開発用のサーバーを起動します。
開発用のサーバーは、デフォルトの設定では127.0.0.1:8787で起動されると思われます。

$ npx wrangler dev

http://127.0.0.1:8787/にアクセスすると、暗黙的にCloudflare Turnstileのウィジェットが読み込まれます。
http://127.0.0.1:8787/?type=explicitにアクセスすると、明示的にCloudflare Turnstileのウィジェットが読み込まれます。
それから、ログインのボタンを押して、下記のようなJSONが表示されたら、Cloudflare Turnstileの認証ができたということになります。

{
	"status":200,
	"message":"OK",
	"data": {
		"challenge_ts": "2025-09-20T02:14:39.709Z",
		"error-codes": [],
		"hostname": "example.com",
		"metadata": {
			"result_with_testing_key": true
		},
		"success": true
	}
}

Cloudflare Turnstileのトークンを再生成する

ユーザーの操作が5分以上かかるような場合には、Cloudflare Turnstileのトークンを再生成する必要があります。
そのような場合には、turnstile.resetメソッドを実行して、Cloudflare Turnstileのトークンを再生成します。

FAQ · Cloudflare Turnstile docs

What happens if the user takes longer than five minutes?
The Turnstile widget needs to be refreshed to generate a new token.
This can be done using the turnstile.reset function.

クリックやサブミットなどのイベントを用いて、何度もリクエストを送信するような場合にも、Cloudflare Turnstileのトークンを再生成する必要があります。
Cloudflare Turnstileのトークンは一度でも使ったら使えなくなってしまいますので、リクエストを送信したらturnstile.resetメソッドを実行して、Cloudflare Turnstileのトークンを再生成します。

テスト用のサイトキーとシークレットキーでは、何度もリクエストを送信したとしても、エラーにはなりません。
ですが、本番環境のサイトキーとシークレットキーでは、何度もリクエストを送信してしまうと、エラーになってしまいます。

<div>
	<div id="turnstile-widget"></div>
</div>

<script>
	let turnstileId = null;

	const form = document.getElementById("some-form");
	form.addEventListener("submit", async (e) => {
		e.preventDefault();

		/*
		const fetchObj = await fetch("https://example.com/");
		...
		*/

		turnstile.reset(turnstileId);
	});

	function turnstileCallback() {
		turnstileId = turnstile.render("#turnstile-widget", {
			sitekey: "1x00000000000000000000AA",
		});
	}
</script>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=turnstileCallback" defer></script>

参考記事