2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【不正登録対策】Turnstile+その他の対策を実装・検討してみた

2
Last updated at Posted at 2025-11-03

はじめに

(全ての信号機を選択してください)
(全ての横断歩道を選択してください)
(全てのバイクを選択してください)

「この画像パネル、微妙にバイクの一部が写ってるけど、カウントするのかな。」
「・・・ポチッ」

(認証が失敗しました。)

「あっ・・・」

冒頭のように、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%低下

出典:Cloudflare公式ブログ

御託はいいから、実装を!

背景はそこまでにして置いて、実装してみましょう!

今回は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のセットアップ

  1. Cloudflare Dashboardにログイン(アカウントなければ作成)

  2. メニューの「Application Security」→「Turnstile」をクリック

  3. 「Add Widget」ボタンをクリック

  4. 「Add Hostnames」ボタン →「Turnstileを使用するドメイン」を入力 →「Add」ボタンをクリック

  5. 「Widget」設定画面で、ウィジェットモード(Widget Mode) を選択:

    • Managed(推奨): 訪問者の情報に基づいてチャレンジの必要性を判断。必要な場合のみチェックボックスを表示(画像選択なし)
    • Non-Interactive: ブラウザチャレンジ実行中、ローディング付きウィジェットを表示。ユーザー操作は不要
    • Invisible: 完全に見えないチャレンジ。ユーザー操作と表示も一切なし
  6. サイトキー(Site Key)シークレットキー(Secret Key) を発行してメモする

  7. 画面下部で「更新」ボタンをクリック

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];
        }
    }
}

重要ポイント:

  1. タイムアウト設定:CloudflareのAPIが遅い場合に備える
  2. エラーハンドリング:ネットワークエラーでもアプリが落ちない
  3. ログ記録:デバッグ用に詳細なログを残す

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);
    }
}

設計のポイント:

  1. 設定チェックを最初に:無効な場合は即座にスキップ
  2. 汎用的なエラーメッセージ:セキュリティ上、詳細を明かさない
  3. 入力値の基本検証: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に特化して通過できた高度なボットへの対策

実装のポイント:

  1. フィールド名を自然にする

    • honeypot, bot_check, hachimitsu_trap → ボットに検知される可能性
    • 絶対に使用されないcompany_name,new_passphrase, fax など正規のフィールド名に偽装する
    • 💡 この例では説明のためhachimitsu_trapを使用していますが、本番環境では自然な名前を推奨
  2. display:noneを避ける

    • 高度なボットはdisplay:noneのフィールドを検知してスキップする可能性がある
    • position:absolute; left:-9999pxで画面外に配置する
    • またはopacity:0; height:0; overflow:hiddenで不可視化
  3. UXを向上する

    • aria-hidden="true" → スクリーンリーダーから除外
    • tabindex="-1" → Tabキーでフォーカスされない
    • autocomplete="off" → ブラウザの自動入力を無効化
  4. エラーメッセージを汎用化

    • ❌ 「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などを使用し、同じデバイスから大量の登録があった場合は登録させない

まとめ

  1. UXとセキュリティは両立できる

    • CAPTCHAは「強力だがUXを犠牲にする」
    • Turnstileは「適切なバランス」
  2. 多層防御の重要性

    • Turnstile単体ではなく、Honeypot、レートリミット、行動分析を組み合わせる
  3. イタチごっこなので、完全な防御は難しい

    • 攻撃者のコストを上げることが目的とし、シンプルなBOTは防ぎつつ、高度に対応してくるBOTに対しては多層防御で「割に合わない」状態を作る

リンク

安心して挑戦できる環境を探している方へ。

C&Pでは、プロダクトづくりを“共に考え、共に育てる“エンジニア仲間を募集中です。
少しでも「話を聞いてみたい」と思っていただけましたら、
まずはこちら↓をのぞいてみてください。
>>C&Pのエンジニア求人を見る<<

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?