Ce guide vaut pour Laravel 11 et plus récent, avec son client HTTP. Deux réglages comptent plus que l'appel : Laravel chiffre chaque cookie qu'il lit sauf indication contraire, et il ignore les proxies tant que vous ne leur faites pas confiance.
Le tag
Dans le layout qu'utilisent vos pages d'inscription, de connexion et de checkout :
{{-- resources/views/layouts/app.blade.php, in <head> --}}
<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>
L'appel
Dans bootstrap/app.php, laissez le cookie du tag non chiffré, et faites confiance à votre proxy :
// 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
})
Lisez la clé via la config, pour que cela fonctionne encore après php artisan config:cache :
// config/services.php
'korzen' => [
'key' => env('KORZEN_API_KEY'),
],
L'appel. timeout et connectTimeout prennent des secondes, fractions comprises :
// 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 est l'ordre dans lequel les headers de la requête sont arrivés : un script qui copie les valeurs d'un navigateur en copie rarement l'ordre. PHP-FPM le garde dans les clés HTTP_* de $_SERVER, du dernier au premier, donc l'appel les inverse là ; php artisan serve les garde dans l'ordre. Derrière nginx, c'est l'ordre dans lequel nginx les transmet, qui est l'ordre d'arrivée (mesuré avec nginx 1.31) ; nginx écarte un nom avec _. Derrière un load balancer ou Cloudflare, c'est l'ordre de ce saut, pas celui du navigateur.
Puis, là où le compte est créé ; avec Breeze, dans 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,
]);
L'IP du visiteur
$request->ip() est l'adresse de votre proxy tant que vous ne lui faites pas confiance. trustProxies(at: '*') fait confiance à tout ce qui se connecte, ce qui est juste quand l'application n'est joignable que par votre load balancer ou Cloudflare ; sinon, listez les adresses de votre proxy. PHP-FPM ouvre une nouvelle connexion pour chaque requête qu'il sert, donc chaque appel comporte une poignée de main TLS : loin de l'Europe, choisissez un timeout qui lui laisse de la place.
Appliquer l'action
Ne lisez que action. Avec block, répondez avec la même erreur que l'action affiche quand elle échoue pour toute autre raison, pour qu'un bot n'apprenne rien. Avec challenge, utilisez la vérification que vous avez déjà (un CAPTCHA, un code par e-mail ou téléphone) ; sans aucune, continuez. Le compte commence en mode monitor, où action est toujours allow : rien ne change pour les visiteurs tant que vous n'activez pas le mode block.
Garder le request_id
Chaque réponse a un request_id. Gardez-le avec ce que l'action a créé, dans une colonne du compte ou de la commande, ou dans une ligne de log avec leur identifiant. Il désigne cette décision quand vous nous interrogez à son sujet, et vous permet de retrouver plus tard quelle réponse a laissé entrer un faux compte.
Tester sur votre machine
Lancez php artisan serve et ouvrez http://localhost:8000. Les pages sur localhost acceptent toujours le tag et n'apparaissent jamais comme domaine. En local, l'IP est 127.0.0.1 ou ::1, et c'est très bien. Dans Docker sur votre machine, l'app voit à la place l'adresse du bridge, comme 172.17.0.1 : la page de challenge vous ramène quand même sur localhost, puisque le tag y a vu votre page.
- Définissez
KORZEN_API_KEYdans.envet ouvrez la page d'inscription. Dans les outils de développement du navigateur, le cookie_kzapparaît après quelques secondes. - Inscrivez-vous. La décision apparaît sur la page Décisions du tableau de bord, sous le chemin que vous avez envoyé.
- Vérifiez le fail-open : mettez une mauvaise clé et inscrivez-vous à nouveau. L'appel répond
401, et l'inscription passe.
Erreurs fréquentes
_kzabsent deencryptCookies(except: …). Laravel n'arrive alors pas à déchiffrer le cookie du tag et$request->cookie('_kz')vautnullà chaque requête.env('KORZEN_API_KEY')dans le service. Aprèsconfig:cache,env()en dehors des fichiers de config renvoienull.- Aucun proxy de confiance derrière un load balancer. Chaque visiteur a alors l'IP du load balancer.
- Appeler
/v1/decidedepuis le navigateur. L'API key atteindrait chaque visiteur ; l'appel a sa place sur votre serveur. - Pas de timeout, ou une erreur qui bloque l'inscription. Toute réponse autre que
200, ou l'absence de réponse à temps, signifie : laissez passer le visiteur.