- TL;DR
- なぜ Turnstile と reCAPTCHA を両方使うのか
- 設計の全体像 — 2 段階のフォールバック
- CaptchaVerifier interface — 抽象化の最小単位
- Turnstile / reCAPTCHA v3 の挙動の違い
- CaptchaManager — provider を「フロント送信値で」振り分ける
- ChecksCaptcha trait — Controller から呼ぶ口
- フロント CaptchaWidget — 自動切替の実装
- API クライアント — エラーレスポンスを受けて切替
- native curl で呼ぶ理由 — 共有レンタルサーバの libcurl 罠
- テスト戦略 — postForm() を protected にする
- 設定 — primary / fallback を対称に
- 罠まとめ
- まとめ
TL;DR
- Cloudflare Turnstile を primary、Google reCAPTCHA v3 を fallback として 2 重に CAPTCHA を構える。
- 切り替えは 2 段階: フロントで widget エラー時の自動切替 + サーバ verify 失敗時の
retry_with_providerレスポンスによるフロント再試行。 - バックエンドは
CaptchaVerifierinterface +CaptchaManagerで provider 抽象化。Manager が 「サーバ側で暗黙に fallback」をやらない設計が肝。 - HTTP 呼び出しは native curl で実装 (Guzzle ではなく)。共有レンタルサーバの古い libcurl で TLS 関連の罠を踏まないため。
- テストは
postForm()をprotectedにして子クラスでスタブ。Laravel の DI で provider 切替まで含めて検証する。
なぜ Turnstile と reCAPTCHA を両方使うのか
Cloudflare Turnstile は ユーザ体験が良く、無料枠も大きい ので primary に採用したい。一方で、運用していると以下が地味に効いてくる:
- Cloudflare 側の障害で widget が落ちる時間帯がある
- 広告ブロックや特定ブラウザ拡張で script が読めないユーザがいる
- 企業ネットワークが Cloudflare の challenge エンドポイントをブロックしているケースがある
これらが起きると、Turnstile だけのサービスは「サインアップできない」という致命的な体験を生む。fallback として Google reCAPTCHA v3 (目立たないスコア型) を組み合わせると、ほとんどの時間帯で Turnstile、そうでない時に v3 が出る、という運用にできる。
ただし 両方の SDK を同時にロードしない のが原則。grecaptcha と turnstile のグローバルが両方乗ると、バンドルも script タグも騒がしくなる。「片方だけ動的にロードし、ダメなら切り替える」設計 が要る。
設計の全体像 — 2 段階のフォールバック
切り替えポイントは 2 つあり、それぞれ別の信号で発火する:
| 層 | 信号 | 切替の主体 |
|---|---|---|
| フロント自動切替 | Turnstile script load failure / error-callback 発火 | ブラウザ内の widget が switchToFallback() を呼ぶ |
| サーバ→フロント指示 | primary verify が失敗、fallback が設定されている | サーバが 422 で retry_with_provider と fallback_site_key を返す |
サーバ側は 「primary が失敗したら自動で fallback で再 verify する」をやらない。理由:
- フロントが送ってきた token は primary 用のもの。fallback 用に流用しても通らない (provider が違う)。
- ユーザに新しい widget で再認証してもらう必要がある。サーバが暗黙に切替えても、結果は「ユーザがもう一度操作するべき」になる。
- 「次は fallback で来てね」とフロントに伝える方が、責務分担として綺麗。
つまりサーバの責務は「今届いた token がどっち用かを見て、その verifier で verify する」だけ。状態機械の主役はフロント。
CaptchaVerifier interface — 抽象化の最小単位
最初に切り出した interface はこれだけ:
interface CaptchaVerifier
{
public function verify(
?string $token,
?string $remoteIp = null,
?string $action = null,
): CaptchaResult;
public function name(): string;
}
verify()— token / IP / action を受け取ってCaptchaResultを返すname()—'turnstile'/'recaptcha_v3'のプロバイダ識別子
戻り値 CaptchaResult は 全プロバイダ共通の値オブジェクト:
final class CaptchaResult
{
public function __construct(
public readonly bool $success,
public readonly array $errorCodes = [],
public readonly ?float $score = null,
public readonly ?string $action = null,
public readonly ?string $hostname = null,
) {}
public static function pass(?float $score = null, /* ... */): self
public static function fail(array $errorCodes = ['unknown']): self
}
Turnstile は score が常に null、v3 では値が入る。プロバイダ固有のフィールドも共通の Result に同居させることで、Controller 側のコードがプロバイダごとに分岐せずに済む。
Turnstile / reCAPTCHA v3 の挙動の違い
API は似ているが、検証ロジックに大きな差 が 2 つある:
| 観点 | Turnstile | reCAPTCHA v3 |
|---|---|---|
| スコア | 無し (success のみ) | あり (0.0〜1.0、低いほどボット) |
| action 検証 | 値は返るが strict 比較なし | strict 比較 (mismatch なら fail) |
| 失敗の理由 | error-codes 配列 | error-codes + 低スコア / mismatch |
v3 側の verify は素朴に書くとこう:
$score = isset($data['score']) ? (float) $data['score'] : null;
$respAction = $data['action'] ?? null;
if ($score !== null && $score < $this->minScore) {
return CaptchaResult::fail(['low-score']);
}
if ($action !== null && $respAction !== null && $action !== $respAction) {
return CaptchaResult::fail(['action-mismatch']);
}
スコア閾値は config('captcha.recaptcha_v3.min_score') から、既定 0.5。本番では 0.3〜0.5 あたりが普通。低すぎると bot が通る、高すぎると人間が弾かれる、というトレードオフ。
action mismatch チェックはリプレイ攻撃対策。「サインアップで取った token をログインで使い回す」を防ぐ。
CaptchaManager — provider を「フロント送信値で」振り分ける
CaptchaManager は 3 メソッドを公開する:
public function primary(): CaptchaVerifier
public function fallback(): ?CaptchaVerifier
public function byName(?string $name): CaptchaVerifier
中心は byName():
public function byName(?string $name): CaptchaVerifier
{
$name = $name !== null ? trim($name) : '';
if ($name === '') {
return $this->primary();
}
if ($name === (string) ($this->config['provider'] ?? '')) {
return $this->primary();
}
$fallback = $this->fallback();
if ($fallback !== null && $fallback->name() === $name) {
return $fallback;
}
// 知らない provider 名 → primary に倒す
return $this->primary();
}
「フロントが送ってきた captcha_provider 名を信じる」設計。サーバが暗黙に fallback しない。「知らない名前」が来たら primary に寄せる (安全側のフォールバック)。
fallback() は config 上で fallback provider 名や secret_key が空ならば null を返す。これによって 「fallback 未配備の環境」も同じ設計で動く。dev 環境では provider を null にして CAPTCHA を切れる。
ChecksCaptcha trait — Controller から呼ぶ口
Controller 共通の trait に集約する:
protected function verifyCaptchaOrFail(Request $request, string $action): void
{
$manager = app(CaptchaManager::class);
$token = $request->input('captcha_token');
$provider = $request->input('captcha_provider');
$verifier = $manager->byName(is_string($provider) ? $provider : null);
$result = $verifier->verify(
token: is_string($token) ? $token : null,
remoteIp: $request->ip(),
action: $action,
);
if ($result->success) {
return;
}
$body = ['message' => 'CAPTCHA verification failed.'];
// primary が失敗 & fallback あり → フロントに切替を指示
$primary = $manager->primary();
$fallbackName = $manager->fallbackName();
if ($verifier->name() === $primary->name() && $fallbackName !== null) {
$body['retry_with_provider'] = $fallbackName;
$body['fallback_site_key'] = $manager->fallbackSiteKey();
}
abort(response()->json($body, 422));
}
ContactController での使用例:
public function store(Request $request): JsonResponse
{
$request->validate([
'captcha_token' => ['nullable', 'string', 'max:4096'],
'captcha_provider' => ['nullable', 'string', 'max:32'],
]);
$this->verifyCaptchaOrFail($request, 'contact');
// ... 業務ロジック
}
action (第 2 引数) は Controller ごとに固定。サインアップなら 'register'、コンタクトなら 'contact'。これが reCAPTCHA v3 の action 一致チェックに繋がる。
フロント CaptchaWidget — 自動切替の実装
React 側はステートマシン的に provider を切り替える。初期値はビルド時の env で決定:
const initialProvider: Provider = PRIMARY_SITE_KEY !== ''
? 'turnstile'
: FALLBACK_SITE_KEY !== ''
? 'recaptcha_v3'
: 'null';
const [provider, setProvider] = useState<Provider>(initialProvider);
Turnstile の自動 fallback はこう:
useEffect(() => {
if (provider !== 'turnstile') return;
loadScript(TURNSTILE_SRC)
.then(() => {
window.turnstile.render(el, {
sitekey: PRIMARY_SITE_KEY,
action,
'error-callback': () => {
console.warn('[Turnstile] error-callback - falling back');
switchToFallback();
},
});
})
.catch((e) => {
console.warn('[Turnstile] script load failed - falling back', e);
switchToFallback();
});
}, [provider, action, switchToFallback]);
切替の発火点は 3 つ:
loadScript()が reject (script タグの load イベントが error)- Turnstile の
error-callback発火 (= widget 内部で何か起きた) - 後述: サーバから
retry_with_providerレスポンスを受けた時、親コンポーネントがswitchToFallback()を呼ぶ
reCAPTCHA v3 側の token 取得:
if (provider === 'recaptcha_v3' && FALLBACK_SITE_KEY !== '') {
await new Promise<void>((resolve) =>
window.grecaptcha!.ready(() => resolve())
);
const tok = await window.grecaptcha.execute(FALLBACK_SITE_KEY, { action });
return { token: tok, provider: 'recaptcha_v3' };
}
v3 は invisible なので「ボタンを押した瞬間に execute()」する。Turnstile のように widget としては描画されない。
公開 API は ref 経由:
type CaptchaWidgetHandle = {
getToken: () => Promise<{ token: string; provider: Provider }>;
switchToFallback: () => void;
reset: () => void;
};
API クライアント — エラーレスポンスを受けて切替
クライアントは token を Body 内に乗せる:
register: (body: {
name: string;
email: string;
password: string;
captcha_token?: string;
captcha_provider?: string;
}) =>
request<AuthResponse>('/api/auth/register', {
method: 'POST',
body: JSON.stringify(body),
})
header に乗せると CORS preflight が増えやすい、というのもあるが、それより Laravel 側の $request->input() で取れる方が trait からアクセスしやすい という実装側の都合。
422 を受けた時に、レスポンス内の retry_with_provider を ApiError に同梱して上に投げる:
if (typeof body.retry_with_provider === 'string') {
retryWithProvider = body.retry_with_provider;
}
if (typeof body.fallback_site_key === 'string') {
fallbackSiteKey = body.fallback_site_key;
}
throw new ApiError(message, res.status, errors, retryWithProvider, fallbackSiteKey);
呼び出し側 (SignUp.tsx 等) は ApiError を catch して widget の switchToFallback() を呼び、getToken() を fallback provider で再実行して、もう一度 register を叩く。フロントが状態遷移の主役、という構図がここで完結する。
native curl で呼ぶ理由 — 共有レンタルサーバの libcurl 罠
両 Verifier の HTTP 呼び出しは Guzzle (Http facade) ではなく native curl_* で書いている。表面的には冗長だが、理由は本番環境にある:
- 共有レンタルサーバの libcurl が古く (例: 7.29.0)、
CURL_SSLVERSION_TLSv1_2等の定数が未定義 - Guzzle がこの定数を
SSL_VERSIONオプションに使うと FATAL - そもそも Guzzle 内部の TLS デフォルト挙動が「サーバの libcurl の素」と一致しない (バージョン依存)
native curl ならオプションを明示できる:
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($form),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => max(1, (int) ceil($timeout)),
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = (string) curl_error($ch);
curl_close($ch);
「Guzzle で書いても動く環境」と「動かない環境」が分かれる時、先に薄く curl ラッパーで書いておくほうが移植可能、というのが教訓。あとからインフラを動かす時に「ライブラリの内部 TLS 実装」を疑わなくて済む。
テスト戦略 — postForm() を protected にする
verify ロジックを単体テストしたいが、本物の Cloudflare/Google を叩きたくない。postForm() を protected メソッド として切り出して、テストで子クラスで上書きする:
class TurnstileVerifier implements CaptchaVerifier
{
protected function postForm(string $url, array $form, float $timeout): array
{
// [body, status, error] を返す
}
}
テストはこう:
private function makeVerifierWith(array $stubbedResponse): TurnstileVerifier
{
return new class(/* constructor args */) extends TurnstileVerifier {
public array $stub;
protected function postForm(string $url, array $form, float $timeout): array
{
return $this->stub;
}
};
}
public function test_returns_pass_when_cloudflare_says_success(): void
{
$verifier = $this->makeVerifierWith([
json_encode(['success' => true, 'hostname' => 'example.com']),
200,
'',
]);
$result = $verifier->verify('valid-token', '203.0.113.42');
$this->assertTrue($result->success);
}
「protected メソッドで外部 I/O を分離する」 は古典的な Laravel テストパターン。HTTP モックライブラリを入れなくていい分、依存関係も浅い。
DI バインディングも別途テスト:
public function test_resolves_turnstile_when_configured(): void
{
config(['captcha.provider' => 'turnstile', 'captcha.secret_key' => 'secret']);
$verifier = $this->app->make(CaptchaVerifier::class);
$this->assertInstanceOf(TurnstileVerifier::class, $verifier);
}
public function test_resolves_null_when_secret_empty(): void
{
config(['captcha.provider' => 'turnstile', 'captcha.secret_key' => '']);
$verifier = $this->app->make(CaptchaVerifier::class);
$this->assertInstanceOf(NullVerifier::class, $verifier);
}
secret_key 未配備 → 自動で NullVerifier (常に pass) に落ちる、という仕様は dev/staging で CAPTCHA を切る ためのもの。これがあると開発フローが止まらない。
設定 — primary / fallback を対称に
// config/captcha.php
return [
'provider' => env('CAPTCHA_PROVIDER', 'turnstile'),
'site_key' => env('CAPTCHA_SITE_KEY', ''),
'secret_key' => env('CAPTCHA_SECRET_KEY', ''),
'fallback' => [
'provider' => env('CAPTCHA_FALLBACK_PROVIDER', ''),
'site_key' => env('CAPTCHA_FALLBACK_SITE_KEY', ''),
'secret_key' => env('CAPTCHA_FALLBACK_SECRET_KEY', ''),
],
'timeout' => (float) env('CAPTCHA_TIMEOUT', 5.0),
'turnstile' => [
'verify_url' => env('CAPTCHA_TURNSTILE_URL', '...'),
],
'recaptcha_v3' => [
'verify_url' => env('CAPTCHA_RECAPTCHA_URL', '...'),
'min_score' => (float) env('CAPTCHA_RECAPTCHA_MIN_SCORE', 0.5),
],
];
primary と fallback の構造を完全に対称にしてある。これによって「primary を v3 に / fallback を Turnstile に」と入れ替える時もコード変更が要らない。env だけで切れる。
罠まとめ
- 両方の SDK を同時にロードしない — script の競合 + バンドル肥大
- サーバ側で暗黙の fallback をしない — token は provider 固有、ユーザ操作も必要
- Turnstile と v3 は API 形が違う —
score/actionの扱いが対称ではない - 共有サーバの libcurl — native curl で書いておくと環境依存を減らせる
- action mismatch チェック (v3) — Controller ごとに固定の action 文字列を渡す
- dev では NullVerifier — secret 未配備で自動的に常時 pass、これで開発フローを止めない
- token は Body 内 — header に乗せると CORS preflight が増えやすい
まとめ
CAPTCHA を 2 重化する設計の核心は:
- interface (
CaptchaVerifier) + Manager で provider を抽象化 - 「サーバが暗黙に fallback しない」 で責務を綺麗に分ける
- フロントが状態機械の主役、サーバは
retry_with_providerで次の動きを指示 - dev/staging では NullVerifier に落とす ことで「CAPTCHA で開発が止まる」を防ぐ
- primary / fallback の設定を対称にして env だけで入れ替え可能 にしておく
このパターンは他のサードパーティ依存 (決済 / OCR / 通知) でも応用が効く。一次選択肢が落ちた時に二次に切り替える、という構造は interface + Manager + フロント自動切替 のセットで形にできる。


コメント