このガイドは Laravel 11 以降と、その HTTP クライアントを対象にしています。呼び出しそのものより重要な設定が 2 つあります。Laravel は指定しない限り読み取るすべての Cookie を暗号化されたものとして扱うこと、そして信頼するまでプロキシを無視することです。
タグ
新規登録、ログイン、チェックアウトの各ページが使うレイアウトに置きます。
{{-- resources/views/layouts/app.blade.php, in <head> --}}
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
呼び出し
bootstrap/app.php で、タグの Cookie を暗号化の対象外にし、プロキシを信頼します。
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['_kz']); // the tag's cookie is not Laravel's
$middleware->trustProxies(at: '*'); // only if the app is reachable through your proxy alone
})
php artisan config:cache の後も動くよう、キーは config から読み取ります。
// config/services.php
'korzen' => [
'key' => env('KORZEN_API_KEY'),
],
呼び出しです。timeout と connectTimeout は秒単位で、小数も指定できます。
// app/Services/Korzen.php
namespace App\Services;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Throwable;
class Korzen
{
/** The headers Bot Protection reads: those one browser build sends alike, and Accept-Language. */
private const HEADERS = ['accept', 'accept-encoding', 'accept-language', 'sec-ch-ua', 'sec-ch-ua-mobile',
'sec-ch-ua-platform', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'upgrade-insecure-requests'];
/** The answer for this request, or null on any error: then go on as on allow (fail open). */
public static function decide(Request $request, string $path): ?array
{
$headers = [];
foreach (self::HEADERS as $name) {
if ($request->hasHeader($name)) {
$headers[$name] = $request->header($name);
}
}
// The order the headers came in, as $_SERVER keeps it: PHP-FPM lists them last first.
$names = array_values(array_filter(array_keys($request->server->all()), fn ($k) => str_starts_with((string) $k, 'HTTP_')));
if (PHP_SAPI === 'fpm-fcgi') {
$names = array_reverse($names);
}
$order = array_map(fn ($k) => strtolower(strtr(substr($k, 5), '_', '-')), array_slice($names, 0, 64));
try {
$res = Http::withToken(config('services.korzen.key'))
->timeout(0.2)
->connectTimeout(0.2)
->post('https://korzen.dev/v1/decide', [
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'headers' => (object) $headers, // {} when empty, never []
'header_order' => $order,
'token' => $request->cookie('_kz'),
'path' => $path,
'method' => 'POST',
]);
return $res->ok() ? $res->json() : null;
} catch (Throwable) {
return null;
}
}
}
header_order はリクエストのヘッダーが届いた順序です。ブラウザの値をコピーするスクリプトでも、その順序まで真似ることはまれです。PHP-FPM はこの順序を $_SERVER の HTTP_* キーに逆順で保つため、そこでは呼び出しが並びを反転します。php artisan serve は届いた順に保ちます。nginx の後ろでは nginx が転送する順序、つまり届いた順序になります(nginx 1.31 で計測)。nginx は _ を含む名前を捨てます。ロードバランサーや Cloudflare の後ろでは、ブラウザの順序ではなく、その中継の順序です。
次に、アカウントを作成する箇所で呼び出します。Breeze なら RegisteredUserController::store の中です。
// app/Http/Controllers/Auth/RegisteredUserController.php (Breeze), in store()
$decision = Korzen::decide($request, '/register');
if (($decision['action'] ?? 'allow') === 'block') {
throw ValidationException::withMessages(['email' => 'We could not create your account.']);
}
// "challenge": your CAPTCHA, or an email or phone check, before going on
$user = User::create([
// ... your fields
'korzen_request_id' => $decision['request_id'] ?? null,
]);
訪問者の IP
プロキシを信頼するまで、$request->ip() はプロキシのアドレスになります。trustProxies(at: '*') は接続してくるものをすべて信頼します。アプリにロードバランサーや Cloudflare 経由でしか到達できないならこれで正しいのですが、そうでなければプロキシのアドレスを列挙してください。PHP-FPM は処理するリクエストごとに新しい接続を開くため、呼び出しのたびに TLS ハンドシェイクが発生します。ヨーロッパから遠い場合は、その分の余裕を持たせたタイムアウトを選んでください。
アクションを適用する
読むのは action だけです。block の場合は、その操作がほかの理由で失敗したときと同じエラーを返し、bot に何も悟らせないようにします。challenge の場合は、すでにある確認手段(CAPTCHA、メールや電話の確認コード)を使います。何もなければそのまま続けます。アカウントは monitor mode で始まり、その間 action は常に allow です。block mode をオンにするまで、訪問者にとっては何も変わりません。
request_id を保存する
すべての応答には request_id が含まれます。操作で作成されたもの(アカウントや注文)のカラムに保存するか、その ID と一緒にログに残してください。お問い合わせの際にその判定を特定でき、後からどの応答が偽アカウントを通したのかを調べられます。
手元のマシンでテストする
php artisan serve を実行し、http://localhost:8000 を開きます。localhost 上のページは常にタグを受け付け、ドメインとして一覧に載ることはありません。ローカルでは IP が 127.0.0.1 または ::1 になりますが、問題ありません。自分のマシンの Docker でアプリを動かすと、代わりにブリッジのアドレス(172.17.0.1 など)になりますが、タグがそこであなたのページを見ているので、チャレンジページは localhost へ戻してくれます。
.envにKORZEN_API_KEYを設定し、新規登録ページを開きます。数秒後、ブラウザーの開発者ツールに_kzCookie が表示されます。- 新規登録します。判定が dashboard の判定ページに、送信したパスで表示されます。
- fail-open を確認します。誤ったキーを設定してもう一度新規登録すると、呼び出しは
401を返し、新規登録はそのまま完了します。
よくある間違い
encryptCookies(except: …)に_kzが入っていない。Laravel がタグの Cookie の復号に失敗し、すべてのリクエストで$request->cookie('_kz')がnullになります。- サービスの中で
env('KORZEN_API_KEY')を使う。config:cacheの後は、config ファイル以外でのenv()はnullを返します。 - ロードバランサーの背後なのに信頼するプロキシを設定していない。すべての訪問者がロードバランサーの IP になります。
- ブラウザーから
/v1/decideを呼び出す。API key がすべての訪問者に渡ってしまいます。呼び出しはサーバーで行ってください。 - タイムアウトがない、またはエラーで新規登録が止まる。
200以外の応答や、時間内に応答がない場合は、訪問者を通すという意味です。