Korzen
Bot Protection

PHP and Symfony

Bot Protection in PHP without a framework or with Symfony: the tag, one class for the call with curl under one timeout, the headers' order, and trusted proxies.

This guide is for PHP 8.1 and later, with the curl extension most builds include; for Laravel, see Laravel. One class serves plain PHP and Symfony alike: it takes $_SERVER, the visitor's IP and the _kz cookie, so each passes its own.

The tag

Put the tag in the <head> of the layout your sign-up, sign-in and checkout pages use (in Symfony, templates/base.html.twig):

<script src="https://korzen.dev/v1/tag.js" data-site-key="YOUR_SITE_KEY" async></script>

The call

CURLOPT_TIMEOUT_MS bounds the whole call, the connection included:

<?php
// src/Korzen.php
namespace App;

final 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).
     * $server is $_SERVER (in Symfony, $request->server->all()); $ip the visitor's IP.
     */
    public static function decide(array $server, ?string $ip, ?string $token, string $path): ?array
    {
        $headers = [];
        foreach (self::HEADERS as $name) {
            $key = 'HTTP_' . strtoupper(strtr($name, '-', '_'));
            if (isset($server[$key])) {
                $headers[$name] = $server[$key];
            }
        }
        // The order the headers came in, as $_SERVER keeps it: PHP-FPM lists them last first.
        $names = array_values(array_filter(array_keys($server), 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));
        $ch = curl_init('https://korzen.dev/v1/decide');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode([
                'ip' => $ip,
                'user_agent' => $server['HTTP_USER_AGENT'] ?? null,
                'headers' => (object) $headers, // {} when empty, never []
                'header_order' => $order,
                'token' => $token,
                'path' => $path,
                'method' => 'POST',
            ]),
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . ($_ENV['KORZEN_API_KEY'] ?? getenv('KORZEN_API_KEY')), // Symfony's .env fills $_ENV
                'Content-Type: application/json',
                'Korzen-Deadline-Ms: 200',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT_MS => 200, // the whole call, the connection included
            CURLOPT_NOSIGNAL => true,  // or libcurl rounds a timeout under a second to none
        ]);
        $res = curl_exec($ch);
        if ($res === false || curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
            return null;
        }
        $d = json_decode($res, true);

        return is_array($d) ? $d : null;
    }
}

header_order is the order the request's headers came in: a script that copies a browser's values seldom copies their order. PHP-FPM keeps it in the HTTP_* keys of $_SERVER, last first, so the call reverses them there; php -S and symfony serve keep them first first. Behind nginx it is the order nginx forwards them in, which is the order they came (measured with nginx 1.31); nginx drops a name with _. Behind a load balancer or Cloudflare, it is that hop's order, not the browser's.

Without a framework

In the script that creates the account:

<?php
// public/signup.php
require __DIR__ . '/../src/Korzen.php';

use App\Korzen;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $decision = Korzen::decide($_SERVER, $_SERVER['REMOTE_ADDR'] ?? null, $_COOKIE['_kz'] ?? null, '/signup');
    if (($decision['action'] ?? 'allow') === 'block') {
        http_response_code(400);
        exit('We could not create your account.');
    }
    // "challenge": your CAPTCHA, or an email or phone check, before going on
    $user = create_account($_POST); // your own code
    save_request_id($user, $decision['request_id'] ?? null); // see "Keep the request_id"
    header('Location: /welcome', true, 303);
    exit;
}

$_SERVER['REMOTE_ADDR'] is the connection's address: the visitor's when nothing stands in front of PHP. Behind a proxy, see "The visitor's IP".

Symfony

Symfony loads src/Korzen.php by itself. Trust your proxy, so getClientIp() is the visitor's:

# config/packages/framework.yaml
framework:
    trusted_proxies: '127.0.0.1,10.0.0.0/8' # your proxies' addresses
    trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host']
<?php
// src/Controller/SignupController.php
namespace App\Controller;

use App\Korzen;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class SignupController extends AbstractController
{
    #[Route('/signup', methods: ['POST'])]
    public function signup(Request $request): Response
    {
        $decision = Korzen::decide($request->server->all(), $request->getClientIp(), $request->cookies->get('_kz'), '/signup');
        if (($decision['action'] ?? 'allow') === 'block') {
            return new Response('We could not create your account.', 400);
        }
        // "challenge": your CAPTCHA, or an email or phone check, before going on
        $user = $this->accounts->create($request->request->all()); // your own code
        $user->setKorzenRequestId($decision['request_id'] ?? null); // see "Keep the request_id"

        return $this->redirect('/welcome', 303);
    }
}

The visitor's IP

Without a framework, REMOTE_ADDR is your proxy's address when you have one. Behind one load balancer or nginx of yours, take the last entry of $_SERVER['HTTP_X_FORWARDED_FOR'], the one your proxy added; behind Cloudflare, $_SERVER['HTTP_CF_CONNECTING_IP'], if your server only takes traffic from Cloudflare. In Symfony, trusted_proxies does it: getClientIp() reads X-Forwarded-For from the right and skips the addresses you trust. PHP-FPM opens a new connection for each request it serves, so each call carries a TLS handshake: from far from Europe, choose a timeout with room for it.

Apply the action

Read only action. On block, answer with the same error the action shows when it fails for any other reason, so a bot learns nothing. On challenge, use the check you already have (a CAPTCHA, an email or phone code); with none, go on. The account starts in monitor mode, where action is always allow: nothing changes for visitors until you turn on block mode.

Keep the request_id

Every answer has a request_id. Keep it with what the action created, in a column of the account or the order, or in a log line with their id. It names that decision when you ask us about it, and lets you find out later which answer let a fake account in.

Test it on your machine

Run php -S localhost:8000 -t public (in Symfony, symfony serve) and open it on localhost. Pages on localhost always accept the tag and are never listed as a domain. Locally the IP is 127.0.0.1 or ::1, and that is fine.

  1. Set KORZEN_API_KEY in the environment and open the sign-up page. In the browser's developer tools, the _kz cookie appears after a few seconds.
  2. Sign up. The decision shows on the dashboard's Decisions page, under the path you sent.
  3. Check fail open: set a wrong key and sign up again. The call answers 401, and the sign-up goes through.

Common mistakes

  • The first entry of X-Forwarded-For. The visitor writes it, so a bot picks its own IP.
  • CURLOPT_TIMEOUT_MS without CURLOPT_NOSIGNAL. Where libcurl resolves names with the system's blocking resolver, a timeout under a second then fails at once, and every call lets the visitor through.
  • getenv() alone for the key in Symfony. Symfony's Dotenv puts .env in $_ENV, not in the process's environment, so the class reads $_ENV first.
  • Calling /v1/decide from the browser. The API key would reach every visitor; the call belongs on your server.
  • No timeout, or an error that stops the sign-up. Any answer other than 200, and no answer in time, means: let the visitor through.