はじめに
(全ての信号機を選択してください)
(全ての横断歩道を選択してください)
(全てのバイクを選択してください)
「この画像パネル、微妙にバイクの一部が写ってるけど、カウントするのかな。」
「・・・ポチッ」
(認証が失敗しました。)
「あっ・・・」
冒頭のように、CAPTCHA(キャプチャ)に悩まされた一人のユーザーとして、
今回は不正登録対策としてよく見る、CAPTCHA・Cloudflare社のTurnstileを見ていきたいと思います!
この記事では、なぜ従来のCAPTCHAではなくTurnstileを選んだのか、そしてどう実装したのかを、実践的なコード例とともに説明します。(※今回はLaravelでの実装を想定しています。)
また、honeypot等、他の対策と合わせた複合策にも触れてみます!
従来の課題
「攻撃者」vs「ボット対策」のイタチごっこに発展している「不正登録防止」ですが、下記の課題があると言われております。
- 「堅牢さ vs UX」をバランスする難しさ
- データプライバシー
1. UX(ユーザー体験)の課題
- 選択肢のパネル画像に〇〇があるのか人間でも分かりにくい
- 画像内の文字を入力するケースでは、人間でも文字が何と書いているか認識できない場合がある
- 画像が小さくて見づらい(特にモバイル)
- タップ精度が求められる
- 何度も繰り返しを要求される
これらを「いい感じ」に解決してくれそうなTurnstileですが、Cloudflare公式データによると:
- 従来のCAPTCHAは平均32秒の解決時間がかかっている
- Turnstileと比較すると、従来のCAPTCHAはページ離脱率が31%高い
2. プライバシーの懸念
見えないCAPTCHAの共通課題
reCAPTCHA v3やCloudflare Turnstileなど、最近の「見えないCAPTCHA」はユーザー操作なしでバックグラウンド動作するため、
従来のCAPTCHAと比較するとUXが向上する代わりに、以下の共通課題があります:
- 行動データの収集:マウス移動、クリック、キーストローク、スクロール動作、デバイス情報、IPアドレスなどを収集
- 透明性の課題:バックグラウンドで動作するため、ユーザーはデータ収集に気づきにくい
- プライバシー保護ユーザーへの不利益:サードパーティークッキーを無効化したブラウザのユーザーや、VPNユーザーは高リスク判定されやすい
reCAPTCHA v3特有の問題
- Googleアカウント優遇:トロント大学の研究によると、Googleアカウントログイン時とそうでない時では、パスする確率に大きな差が確認された(出典:トロント大学研究論文)
- セキュリティ以外の目的でのデータ使用:フランスのデータ保護機関CNILが2023年12月、「reCAPTCHAは認証メカニズムの保護という単一の目的ではなく、Googleによる分析操作も可能にする」と公式に認定(出典:CNIL裁定 SAN-2023-023)
- GDPR非準拠・罰金事例:CNILは「reCAPTCHAは"厳密に必要"なクッキー例外に該当しない」と判断し、明示的な同意をユーザーから得ることが必要と結論。NS Cards France社は同意取得なしのreCAPTCHA利用を違反で€15,000の罰金(出典:CNIL裁定 SAN-2023-023)
Turnstileが「いい感じ」にやってくれそうな点
1. プライバシー重視の設計
- ✅ 追跡クッキーは使用しない(出典:Cloudflare公式ブログ)
- ✅ 広告ターゲティングにデータを使用しない明言
- ✅ GDPR準拠を考慮した設計
- ⚠️ただし、 行動データ(マウス移動、キーストローク等)とセッションデータは収集される
2. 優れたUX
Cloudflare公式データによる比較:
| 項目 | 従来のCAPTCHA | Turnstile |
|---|---|---|
| ユーザー操作 | 画像選択チャレンジ | ほぼ自動 |
| 所要時間 | 平均32秒 | 平均1秒 |
| ページ離脱率 | 基準 | 31%低下 |
御託はいいから、実装を!
背景はそこまでにして置いて、実装してみましょう!
今回はLaravel 12(PHP)での実装をしておりますが、
「JSによってviewファイルに付与されたトークン」をバックエンドでCloudflareのAPI経由で検証する流れは、どの言語でも同様になります!
※コンストラクタ・インポートに特筆すべき点がなければ、省略します!
アーキテクチャ・全体像
私が採用したアーキテクチャは以下の3つで構成されています:
┌─────────────────────────────────────┐
│ 1. StoreUserRequest │ Form Request(リクエスト受付・基本バリデーション)
│ - withValidator() │
└─────────────────────────────────────┘
↓ 呼び出し
┌─────────────────────────────────────┐
│ 2. TurnstileRule │ カスタムバリデーションルール(トークン検証の統合)
│ implements ValidationRule │
└─────────────────────────────────────┘
↓ 呼び出し
┌─────────────────────────────────────┐
│ 3. TurnstileService │ 外部API通信(CloudflareでBot判定を実行)
│ - isConfigured() │
│ - verify($token) │
└─────────────────────────────────────┘
なぜこのアーキテクチャなのか
- 再利用したい
- 単一責任の原則:コントローラー内だと「HTTPリクエスト処理」+「Turnstile認証」、バリデーション内だと「入力値判定」+「Turnstile認証」の2つの責任を持ってしまう
ステップバイステップ実装
Step 1: Cloudflare Turnstileのセットアップ
-
Cloudflare Dashboardにログイン(アカウントなければ作成)
-
メニューの「Application Security」→「Turnstile」をクリック
-
「Add Widget」ボタンをクリック
-
「Add Hostnames」ボタン →「Turnstileを使用するドメイン」を入力 →「Add」ボタンをクリック
-
「Widget」設定画面で、ウィジェットモード(Widget Mode) を選択:
- Managed(推奨): 訪問者の情報に基づいてチャレンジの必要性を判断。必要な場合のみチェックボックスを表示(画像選択なし)
- Non-Interactive: ブラウザチャレンジ実行中、ローディング付きウィジェットを表示。ユーザー操作は不要
- Invisible: 完全に見えないチャレンジ。ユーザー操作と表示も一切なし
-
サイトキー(Site Key) とシークレットキー(Secret Key) を発行してメモする
-
画面下部で「更新」ボタンをクリック
Step 2: 環境変数の設定
.envに追加:
# Turnstile設定
TURNSTILE_SITE_KEY=site_key
TURNSTILE_SECRET_KEY=secret_key
config/app.phpに追加:
'turnstile_site_key' => env('TURNSTILE_SITE_KEY', ''),
'turnstile_secret_key' => env('TURNSTILE_SECRET_KEY', ''),
'turnstile_error_msg' => 'Bot対策認証に失敗しました。再度お試しください。',
Step 3: TurnstileService実装
app/Services/TurnstileService.phpを作成:
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
class TurnstileService
{
protected $client;
public function __construct()
{
// HTTPクライアントを適切な設定で作成
$this->client = new Client([
'timeout' => 10, // リクエスト全体のタイムアウト(秒)
'connect_timeout' => 5, // 接続確立のタイムアウト(秒)
]);
}
/**
* Turnstileが設定されているかを確認
*/
public function isConfigured(): bool
{
// サイトキー+シークレットキーが設定されている
return !empty(config('app.turnstile_site_key', '')) &&
!empty(config('app.turnstile_secret_key', ''));
}
/**
* TurnstileトークンをCloudflare APIで検証
*
* @param string $token フロントエンドから送信されたTurnstileトークン
* @return array ['success' => bool, 'error-codes'? => array]
*/
public function verify(string $token): array
{
// Turnstileシークレットキー
$turnstile_secret_key = config('app.turnstile_secret_key', '');
// Cloudflare Turnstile検証エンドポイント
$turnstile_verify_url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
try {
// CloudflareのAPIにPOSTリクエストを送信
$response = $this->client->post($turnstile_verify_url, [
'json' => [
'secret' => $turnstile_secret_key,
'response' => $token,
],
]);
// レスポンスボディを取得してJSONデコード
$body = $response->getBody()->getContents();
$result = json_decode($body, true);
// レスポンスが空の場合は失敗扱い
if (empty($result)) {
return ['success' => false];
}
// Cloudflareからのレスポンスをそのまま返す
// 例: ['success' => true] または ['success' => false, 'error-codes' => [...]]
return $result;
} catch (GuzzleException $e) {
// ネットワークエラーやタイムアウトなどの例外をキャッチ
Log::error('Turnstile認証に失敗しました: '.$e->getMessage(), [
'exception_class' => get_class($e),
'token_length' => strlen($token),
'is_configured' => $this->isConfigured(),
]);
// 例外発生時は失敗として扱う
return ['success' => false];
}
}
}
重要ポイント:
- タイムアウト設定:CloudflareのAPIが遅い場合に備える
- エラーハンドリング:ネットワークエラーでもアプリが落ちない
- ログ記録:デバッグ用に詳細なログを残す
Step 4: TurnstileRule実装
app/Rules/TurnstileRule.phpを作成:
class TurnstileRule implements ValidationRule
{
/**
* バリデーション実行
*
* @param string $attribute バリデーション対象の属性名('cf-turnstile-response')
* @param mixed $value フロントエンドから送信されたTurnstileトークン
* @param Closure $fail バリデーション失敗時に呼び出すクロージャ
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Turnstile機能が無効、または設定不足の場合は認証をスキップ
if (!$this->turnstile_service->isConfigured()) {
return; // バリデーション通過(開発環境などで便利)
}
// セキュリティ上、詳細を明かさない汎用的なエラーメッセージ
$generic_error = config('app.turnstile_error_msg', 'Bot対策認証に失敗しました。再度お試しください。');
// 入力値の基本検証
if (empty($value) || !is_string($value) || strlen($value) > 2048) {
$fail($generic_error);
return;
}
// CloudflareのAPIにトークンを送信して検証
$result = $this->turnstile_service->verify($value);
// 認証成功の場合は処理を終了
if (isset($result['success']) && $result['success'] === true) {
return;
}
// 認証失敗の場合はエラーを追加
$fail($generic_error);
}
}
設計のポイント:
- 設定チェックを最初に:無効な場合は即座にスキップ
- 汎用的なエラーメッセージ:セキュリティ上、詳細を明かさない
- 入力値の基本検証:APIコール前にサニタイズ
Step 5: Form Request作成
app/Http/Requests/StoreUserRequest.phpを作成:
class StoreUserRequest extends FormRequest
{
// authorize()、rules()、messages()は省略
/**
* バリデーション後の追加処理
* Turnstile認証をここで実行することで、基本的なバリデーションと分離
*
* @param Validator $validator Laravelのバリデーターインスタンス
*/
public function withValidator(Validator $validator): void
{
// 基本バリデーション(email、passwordなど)が完了した後に実行されるフック
$validator->after(function (Validator $validator) {
// TurnstileRuleのインスタンスを生成
$turnstile_rule = new TurnstileRule();
// Turnstile検証を実行
$turnstile_rule->validate(
'cf-turnstile-response', // 属性名
$this->input('cf-turnstile-response'), // フロントエンドから送信されたトークン
function (string $message) use ($validator) {
// 検証失敗時のコールバック:エラーメッセージをバリデーターに追加
$validator->errors()->add('cf-turnstile-response', $message);
}
);
});
}
}
なぜwithValidator()を使うのか:
// ❌ 基本検証と混ぜるアンチパターン
public function rules(): array
{
return [
'email' => ['required', 'email'],
'cf-turnstile-response' => [new TurnstileRule()], // API呼び出し!
];
}
// 問題:emailが空でもAPI呼び出しが発生してしまう
// ✅ withValidator()で分離
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
// 基本検証が通った後のみ実行
});
}
// メリット:無駄なAPI呼び出しを削減
Step 6: Blade条件ディレクティブを登録
全Bladeで簡潔にTurnstileの有効可否を分岐できるよう、Bladeの条件ディレクティブを登録します。
app/Providers/AppServiceProvider.php:
use Illuminate\Support\Facades\Blade;
use App\Services\TurnstileService;
public function boot(): void
{
Blade::if('turnstile', fn () => app(TurnstileService::class)->isConfigured());
}
これで、Blade内で@turnstile ... @endturnstileと書くだけで、Turnstileが有効なときだけレンダリングされます。
Step 7: Bladeテンプレート
resources/views/auth/register.blade.php:
<head>内に追加:
@turnstile
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
@endturnstile
フォーム内に追加:
<form method="POST" action="{{ route('register') }}">
@csrf
<!-- 既存のフィールド(name、email、passwordなど) -->
{{-- Turnstile設定が有効な場合のみウィジェットを表示 --}}
@turnstile
<div class="form-group">
{{-- Cloudflare Turnstileウィジェット --}}
<div class="cf-turnstile"
data-sitekey="{{ config('app.turnstile_site_key') }}" {{-- サイトキー --}}
data-language="ja"> {{-- 言語設定(日本語) --}}
</div>
{{-- バリデーションエラー表示 --}}
@error('cf-turnstile-response')
<div class="error">{{ $message }}</div>
@enderror
</div>
@endturnstile
<button type="submit">登録</button>
</form>
Step 8: コントローラー
app/Http/Controllers/AuthController.php:
class AuthController extends Controller
{
/**
* ユーザー登録処理
*
* @param StoreUserRequest $request バリデーション済みのリクエスト
*/
public function register(StoreUserRequest $request)
{
// Form Requestによる自動バリデーション完了後(Turnstile検証も含む)
$validated = $request->validated();
// 新規ユーザーをデータベースに作成
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']), // パスワードをハッシュ化
]);
// 登録完了後、ログインページにリダイレクト
return redirect('/login')->with('success', '登録が完了しました!ログインしてください。');
}
}
- 【これにて主な実装は完了です!🎉】
テスト用キーの活用
Cloudflareの提供するテスト用キーがありますが、これらを.envに設定するとローカルやテストで確認ができます!
# 常に成功
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# 常に失敗
TURNSTILE_SITE_KEY=2x00000000000000000000AB
TURNSTILE_SECRET_KEY=2x0000000000000000000000000000000AB
# チャレンジを表示
TURNSTILE_SITE_KEY=3x00000000000000000000FF
TURNSTILE_SECRET_KEY=3x0000000000000000000000000000000FF
よりBotを困らせたい!
Honeypotとの組み合わせ
Turnstileだけでは防げない高度なボットに対して、Honeypot(罠)も併用します。
Honeypot実装:
{{-- 人間には見えない、ボットが入力することを期待するフィールドを<form>内に設置 --}}
<input type="text"
name="hachimitsu_trap"
style="position:absolute;left:-9999px"
tabindex="-1"
autocomplete="off"
aria-hidden="true">
バリデーション:
public function rules(): array
{
$honeypot_field = config('app.honeypot_field_name', 'hachimitsu_trap');
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'confirmed'],
$honeypot_field => ['nullable', 'max:0'], // 空であるべき
];
}
public function messages(): array
{
$honeypot_field = config('app.honeypot_field_name', 'hachimitsu_trap');
return [
// 通常のバリデーションメッセージ...
"{$honeypot_field}.max" => '入力内容に問題があります。再度お試しください。',
];
}
なぜ効果的か:
- ボットはすべてのフィールドを埋めようとする
- 人間は画面外のフィールドを見ない・入力しない
- Turnstileに特化して通過できた高度なボットへの対策
実装のポイント:
-
フィールド名を自然にする
- ❌
honeypot,bot_check,hachimitsu_trap→ ボットに検知される可能性 - ✅ 絶対に使用されない
company_name,new_passphrase,faxなど正規のフィールド名に偽装する - 💡 この例では説明のため
hachimitsu_trapを使用していますが、本番環境では自然な名前を推奨
- ❌
-
display:noneを避ける- 高度なボットは
display:noneのフィールドを検知してスキップする可能性がある -
position:absolute; left:-9999pxで画面外に配置する - または
opacity:0; height:0; overflow:hiddenで不可視化
- 高度なボットは
-
UXを向上する
-
aria-hidden="true"→ スクリーンリーダーから除外 -
tabindex="-1"→ Tabキーでフォーカスされない -
autocomplete="off"→ ブラウザの自動入力を無効化
-
-
エラーメッセージを汎用化
- ❌ 「hachimitsu_trapは0文字以下でなければなりません。」→ フィールド名が露呈し、Honeypotだと気づかれる
- ✅ 「入力内容に問題があります。再度お試しください。」→ Honeypotであることを隠蔽
- Turnstileと同じく、詳細を明かさないことで攻撃者に手がかりを与えない
このように、CSSで不可視化せず物理的に画面外に配置することで、ボットの検知を回避することを期待します。
多層防御:
Layer 1: 何かしらのレートリミットで大量登録を防ぐ
↓
Layer 2: 基本バリデーション
↓
Layer 3: Honeypot (罠フィールド)
↓
Layer 4: Turnstile (ボット検証)
CAPTCHA自動解決サービスを使用した攻撃への対策
CAPTCHA解決サービスとは
- API経由でCAPTCHAを自動的に解決するサービスが存在する
- 料金体系の例:サービスによって異なるが、1,000回あたり数ドル程度のものも存在する
Turnstileは回避されるのか?
結論:完全な防御は不可能だが、大幅に難易度を上げられる
対策1: チャレンジモードの利用
Turnstileの設定でManagedモード(適応型) を使用すると:
- 疑わしいトラフィックには追加チャレンジ
- 攻撃者の解決コストが上がる
対策2: 行動分析との組み合わせ
- 例:フォーム送信までの時間を記録し、明らかに人間とかけ離れている入力スピードのリクエストは弾く
対策3: デバイスフィンガープリント
- 例:FingerprintJSなどを使用し、同じデバイスから大量の登録があった場合は登録させない
まとめ
-
UXとセキュリティは両立できる
- CAPTCHAは「強力だがUXを犠牲にする」
- Turnstileは「適切なバランス」
-
多層防御の重要性
- Turnstile単体ではなく、Honeypot、レートリミット、行動分析を組み合わせる
-
イタチごっこなので、完全な防御は難しい
- 攻撃者のコストを上げることが目的とし、シンプルなBOTは防ぎつつ、高度に対応してくるBOTに対しては多層防御で「割に合わない」状態を作る
リンク
安心して挑戦できる環境を探している方へ。
C&Pでは、プロダクトづくりを“共に考え、共に育てる“エンジニア仲間を募集中です。
少しでも「話を聞いてみたい」と思っていただけましたら、
まずはこちら↓をのぞいてみてください。
>>C&Pのエンジニア求人を見る<<