korzen / bot-protection
Korzen Bot Protection for PHP: asks POST /v1/decide before a protected action and fails open. A PSR-15 middleware and a Laravel middleware.
Requires
- php: >=8.2
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
None
Suggests
- ext-curl: bounds the whole call to Bot Protection by its timeout, the name lookup too; without it the stream transport bounds all but the lookup
- symfony/http-foundation: for the Laravel middleware, Korzen\BotProtection\Laravel\Protect (Laravel brings it)
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-02 19:15:25 UTC
README
korzen/bot-protection asks Korzen Bot Protection
about a request before a protected action (POST /v1/decide), reports events and feedback
after it (POST /v1/events, POST /v1/feedback), on API v1, and fails open: when Bot
Protection is slow, down, refuses the key or answers anything but a decision, the visitor
goes through. A PSR-15 middleware (Slim, Mezzio, any PSR-15 stack) and a Laravel
middleware; no PHP extension needed, and the PSR interfaces as its only dependencies.
Install
composer require korzen/bot-protection
Use (Slim)
use Korzen\BotProtection\Client; use Korzen\BotProtection\Ip; use Korzen\BotProtection\Middleware; $client = new Client((string) getenv('KORZEN_API_KEY')); $app->post('/checkout', $checkout)->add(new Middleware( $client, $app->getResponseFactory(), ip: Ip::lastForwarded(...), // behind your own load balancer or nginx )); // In $checkout: $request->getAttribute('korzen.decision'), a Decision or null
The key is required. getenv() answers false and Laravel's config() answers null
when it is unset, which Client refuses with a TypeError under strict_types: the
(string) cast keeps the server starting, and with an empty key every call is answered
401 and lets the visitor through, as any failure does.
The middleware answers a block with 403, sends a challenged page load (GET or HEAD) to the
challenge page and back (and refuses a challenged form post with 403), and otherwise runs
your handler with the decision as the request attribute korzen.decision (null when
there is none). Guard the page that holds a form too, so a person meets the challenge
there. $client->decide(DecideRequest::from($request, Ip::socket($request))) makes the
call alone, for a server that applies the decision itself: it returns a Decision, or
null on any failure, and never throws. On sign-in, sign-up and checkout, refuse a
challenge as a block unless your own check (a CAPTCHA, an email or phone code) or the
challenge page runs: never let it through. Most scripts that send a browser's User-Agent
are answered challenge, not block.
PSR-15 (Mezzio and others)
new Middleware($client, $responseFactory, ...) takes any PSR-17 response factory:
$app->pipe(...) it in Mezzio, or add it to any PSR-15 stack.
Laravel
Bind the client once, alias the middleware, and leave its two cookies unencrypted: the tag
reads _kz in the browser, and Laravel would otherwise encrypt it.
// app/Providers/AppServiceProvider.php, in register() $this->app->singleton(Client::class, fn () => new Client((string) config('services.korzen.key'))); // bootstrap/app.php ->withMiddleware(function (Middleware $middleware) { $middleware->alias(['protect' => \Korzen\BotProtection\Laravel\Protect::class]); $middleware->encryptCookies(except: ['_kz', '_kz_c']); }) // routes/web.php: protect:/signup names the action Route::post('/signup', SignupController::class)->middleware('protect:/signup'); // In the controller: $request->attributes->get('korzen.decision')
Protect works on Symfony HttpFoundation's request and response, so it fits any
HttpFoundation stack. For options, bind it:
$this->app->bind(Protect::class, fn ($app) => new Protect($app->make(Client::class), publicUrl: 'https://shop.example')).
Behind a proxy Laravel trusts, ip: fn ($request) => $request->ip() sends the visitor's
address.
Payment, login and sign-up routes
Turn on requireToken on the routes a script goes after: the post that charges a card or
places an order, the one that checks a password, the one that creates an account. A script
that never loads your page (card testing from a new residential IP for each try) runs no
tag and is answered unknown, which is allowed. With the option, the action goes on only
with a token the tag earned in that browser; without one it is answered challenge with
token_required, the class unchanged. A page load spends no token, so guard the form's
page too.
$app->post('/checkout', $checkout)->add(new Middleware( $client, $app->getResponseFactory(), requireToken: true, // on actions only: GET, HEAD and OPTIONS spend no token ));
Laravel's Protect takes the same option: bind it with
new Protect($app->make(Client::class), requireToken: true) for the routes that need it.
Start in monitor mode, and watch the share of decisions without a token before you turn on block mode: a visitor whose browser does not run the tag (an ad blocker, a DNS filter, JavaScript off) holds no token, and cannot pass the challenge page on these routes. Leave the option off the routes no page of yours leads to (webhooks, a mobile app's API). More: https://korzen.dev/bot-protection/docs/protecting-a-checkout.
Options
Both middlewares take the same options, by name:
| Option | What it does |
|---|---|
ip |
reads the visitor's IP: Ip::socket(...) (default), Ip::lastForwarded(...), Ip::cloudflare(...), or yours; the scheme (http/https) is read from the same edge |
path |
names the action in the decision, instead of the request's path |
onDecoy |
fn ($request, Decision $d) answers a block or a challenge marked decoy with your decoy, first; null has the default decoy answer (202 with {} on an action) |
onBlock |
fn ($request, Decision $d) answers a blocked request instead of the 403; null lets it through |
onChallenge |
fn ($request, Decision $d) answers a challenged request instead of the redirect; null lets it through |
publicUrl |
the origin your visitors see, behind a proxy that forwards Host: localhost; checked at start |
returnTo |
fn ($request): string, the address to come back to from the challenge page |
requireToken |
sends require_token on every action (not GET, HEAD or OPTIONS): the action needs the tag's token |
firstParty |
first-party mode: serves firstPartyPath and forwards it; off by default |
firstPartyPath |
the path first-party mode serves, FirstParty::DEFAULT_PATH (/kz) when null |
cdn |
the CDN in front whose headers carry the visitor's JA4: Cdn::CLOUDFRONT and the others; none when null |
A block or a challenge marked decoy (you turned the decoy on for its class on Set-up)
goes to your decoy handler when you give one. Without it, an action (POST, PUT,
PATCH, DELETE) is answered 202 with {} as application/json, an empty success
with no header of Korzen's, and your handler never runs; a page load (GET, HEAD) is
refused as any other, a 403 or the challenge page, because a blank page would break your
site for a person judged a bot by mistake. Your handler replaces the default.
The call goes through curl when ext-curl is loaded (CurlTransport), else a socket of
PHP's own (StreamTransport). Load ext-curl where you can: it bounds the whole call by
the timeout, the name lookup too (a libcurl built with the system's synchronous resolver
gets the name resolved within the time left and pinned). The socket bounds the name lookup
(the hosts file, then the nameservers /etc/resolv.conf names, asked over UDP until the
time left runs out: never the system's lookup, which no PHP timeout bounds, but where no
nameserver is named, as on Windows), connecting, the TLS handshake, and every read of the
headers and the body by the time left, so an answer sent a few bytes at a time costs the
visitor no more than the timeout. A Transport of yours must keep to the deadline too:
PHP cannot cut a call short, and an answer after it is taken for none (no decision,
nothing recorded) though the visitor waited for it. A batch's retries wait the answer's
Retry-After, else the delay doubled each time (at most 2^30 times); a wait past the
batch's time left ends the batch at once, nothing recorded. Measured on 2026-10-22, ten
calls to korzen.dev took 122 ms at the median through curl and 206 ms through PHP's stream
wrapper, with one at 5.3 s. Each call opens its own connection: libcurl sends a POST again
when a kept connection dies before the answer, and a decide sent twice could spend a token
the visitor earned once.
The client is
new Client($apiKey, url: 'https://korzen.dev', timeoutMs: 200, transport: null, logger: null, onAnswer: null, batchTimeoutMs: 5000, retries: 2, retryDelayMs: 200, sdk: null).
The timeout bounds the whole call to /v1/decide and is sent as Korzen-Deadline-Ms: the
decision takes half, the network the other half. The settings are read once, when the
client is made, and each that does not read is logged once (never the key) and left at its
default, so no setting makes a call throw: the key is trimmed of the spaces and line
breaks around it (an empty one is logged: every call fails open); url is an https URL,
or http on loopback (localhost, 127.0.0.0/8, [::1]) for a test on your machine, and
any other (http elsewhere would send the key in clear) calls https://korzen.dev;
timeoutMs is more than 0 and at most 1000 (Client::MAX_TIMEOUT_MS), else 200;
batchTimeoutMs and retryDelayMs are more than 0, and retries a whole number from 0
(null is the default, 2). An error handed to onAnswer never holds the key: one whose
message or trace would is replaced by one that names its class. Every call names its
integration in Korzen-Sdk, Client::SDK (php/<Client::VERSION>) unless sdk names
another, as a plugin built on the SDK does (wordpress/1.0.0). The logger,
fn (string $message), is told once when the IP sent is on your own network (error_log
by default). The helpers the middlewares are made of are public: DecideRequest::from(),
Challenge::url(), Challenge::fromChallenge(), Challenge::redirect(),
Cookies::token(), Cookies::challenge(), ReturnTo::request(),
ReturnTo::publicUrl(), Cdn::fields().
Behind a CDN
Several CDNs forward the visitor's JA4 (the fingerprint of their TLS handshake) to your
server in their own headers, and CloudFront also their header order and HTTP version. The
cdn option names the CDN your server stands behind, and only then are its headers read:
anyone can send them to a server reachable around its CDN, so name a CDN only when your
server takes traffic from it alone, as with the ip option.
cdn |
Header | Turn it on |
|---|---|---|
Cdn::CLOUDFRONT |
CloudFront-Viewer-JA4-Fingerprint and more |
add CloudFront-Viewer-JA4-Fingerprint, -Header-Order and -Http-Version to the origin request policy |
Cdn::AZURE_FRONT_DOOR |
X-Azure-JA4-Fingerprint |
on already |
Cdn::VERCEL |
x-vercel-ja4-digest |
on already |
Cdn::BUNNY |
CDN-JA4 |
on already |
Cdn::GOOGLE_CLOUD |
X-Client-JA4 |
a custom request header X-Client-JA4:{tls_ja4_fingerprint} on the load balancer's backend service |
Cdn::FASTLY |
X-Client-JA4 |
set req.http.X-Client-JA4 = tls.client.ja4; in vcl_recv |
$app->add(new Middleware($client, $app->getResponseFactory(), ip: Ip::lastForwarded(...), cdn: Cdn::CLOUDFRONT));
A value that does not read (a JA4 outside its pattern, a header sent twice, an order with
a name that is no header name, a version not listed) is sent as none, never an error. With
a CDN named, the header order your server received, which is the CDN's, is not sent.
Cdn::fields($request, Cdn::VERCEL, $decideRequest) fills a DecideRequest of yours the
same way, a field you set winning over the CDN's.
The decision
A Decision is API v1's answer: action (apply it alone), requestId (keep it with an
order, to send feedback by it), class, botScore, humanScore, reasons, rules
([] unless rule names are on for your tenant, never null), stepUp, intendedAction,
mode (monitor, block or paused), challenge (a ChallengePage with its url and
passed, or null), tokenRequired, seenBefore, crawler (a Crawler with its
name and kind, or null) and decoy (true only on a block or a challenge Korzen
would rather answer with your decoy, see onDecoy). Back from the challenge page, only an
API v1 token (base64url, up to 160 characters) is kept as the _kz cookie, and the
visitor goes on to the address without kz_token and kz_challenge, the rest of its
query byte for byte: a presigned or an OAuth address keeps its order and its spelling, so
it still verifies. The way there is the address's path and query alone, a relative
Location, unless you set publicUrl or returnTo: a host a request names
(X-Forwarded-Host, which a visitor may write) never goes in it, and a path that opens
with // or /\ is one slash, so the visitor stays on your site.
Of two _kz cookies (one set for a parent domain by other code) the first in the Cookie
header is the token, as the tag and Korzen's server read it: set no _kz cookie of your
own for a parent domain, or it shadows the tag's.
The token is read from the x-korzen-token header when the page sends one
(korzen.token() to an API on another origin, or from a frame, where the cookie does not
travel), else from the _kz cookie: you add nothing on the server. A header that is empty
or no token leaves the cookie (DecideRequest::TOKEN_HEADER).
The context
DecideRequest's context (or withContext()) takes what your server knows of the
person behind the request: decide() normalises it and sends only hashes and the fields
in clear, so the raw email, phone, card fingerprint and ids never leave your server. A
value that does not read as its kind is left out, never an error; an absent field is never
evidence of a bot.
$context = new Context( email: $email, // its domain sent, the address hashed phone: $phone, // E.164, spaces, dashes, dots and brackets allowed phoneCountry: 'NL', phoneType: 'mobile', // one of Context::PHONE_TYPES cardBin: $cardBin, // the first 6 to 8 digits; past 8 left out, and logged once cardFingerprint: $fingerprint, // Stripe's card.fingerprint accountId: $accountId, resourceId: $orderId, ); $client->decide(DecideRequest::fromVisit($visit, $ip)->withPath('/signup')->withContext($context));
On an action with requireToken, the context's identityId (the account, a verified
email, a fan id) with DecideRequest's placesPerIdentity (1 to 10, 1 when left out) is
how many sessions of one identity may take the path in an hour: past them the answer is
challenge with identityLimited, and no challenge page. Tell the person the identity
holds its places.
Every Korzen SDK keys the context alike and passes the cases of the API's contract.json:
the email's domain an A-label, the address in NFC and lower case, its +tag cut,
gmail.com's dots taken out. An address past ASCII needs ext-intl, and is left out
without it. PHP has no UTS #46, so an email whose domain holds a character outside the
Latin, Greek, Cyrillic, Japanese, Chinese and Korean letters (a full-width letter, a
ligature) is left out rather than hashed apart from the other SDKs.
Events and feedback
$client->events([new Event('page', path: '/checkout', method: 'GET', ip: $ip)]); $client->feedback([new Feedback('chargeback', requestId: $order->korzenRequestId)], idempotencyKey: $order->id);
Each sends 1 to 1000 items under an Idempotency-Key (yours, or a fresh UUID v7) and
sends the batch again under the same key, at most retries times within batchTimeoutMs,
when it got no answer, a 409, a 429 or a 5xx, waiting Retry-After or retryDelayMs
doubled each time. They return a BatchAnswer (accepted, duplicates, rejected,
errors), or null when nothing was recorded, and never throw. An Event carries a
decision's fields, the edge's too (ja4, httpProtocol, tlsGroup, country, asn),
each bounded as a decision's is: cut, a country longer than 2 bytes left out.
What each call ended with
onAnswer: fn (CallAnswer $a) => … is told of each call, for your logs: its route,
status (0 when no answer came), requestId (Korzen-Request-Id, to name when you ask
us about a call), rateLimit (the RateLimit-Policy and RateLimit headers, read by
RateLimit::parse(): never wait on them), replayed, problem (an error answer's
application/problem+json, as a Problem with its code) and error. PHP cannot bound
a function it calls, so onAnswer is told after the call, never in it: when the request
ends (a shutdown function), or at $client->flush(). A slow one costs no decision, and
what it throws is logged once and costs nothing. At most Client::MAX_HOOKS (64) answers
wait for it. A long-running worker (RoadRunner, Swoole, FrankenPHP's worker mode), whose
process outlives the request, calls $client->flush() after each response.
Fail open
A hook of yours that throws (an Exception or an Error), a challenge page that is no
http(s) URL of visible ASCII within 2048 characters, a response that cannot be built:
each lets the request through to your handler, with the decision as korzen.decision, and
is logged once through the client's logger, with the error's class and never its message.
Your handler's own exceptions are yours: they go on as they are.
First-party mode
A DNS filter, an ad blocker or a network that blocks korzen.dev stops the tag, its
beacon and the challenge page, so those visitors are judged with less, and on a
requireToken route they cannot pass. First-party mode serves all three from your own
domain, under /kz by default, and forwards them to Bot Protection with the visitor's
address. Put the middleware on that path too, with the same options, and the page loads
the tag from it:
// Slim: every route, /kz/* among them $app->add(new Middleware($client, $app->getResponseFactory(), ip: Ip::lastForwarded(...), firstParty: true)); // Laravel: bind it with the mode on (AppServiceProvider), and route /kz/* through it // outside the web group, whose session and CSRF check a beacon has no part in // (bootstrap/app.php) $this->app->bind(Protect::class, fn ($app) => new Protect($app->make(Client::class), firstParty: true)); ->withRouting(web: __DIR__.'/../routes/web.php', then: function () { Route::any('/kz/{any}', fn () => abort(404))->where('any', '.*')->middleware(Protect::class); })
<script src="/kz/tag.js" data-site-key="sk_..." async></script>
- What it serves.
GET /kz/tag.js,GET /kz/n,POST /kz/s(a body of at most 64 KiB),GET /kz/ping(answered204by the middleware itself, never forwarded),GET/POST /kz/challenge(a post of at most 16 KiB),GET /kz/challenge/waitandGET /kz/static/<file>. Any other path under/kzis a404, a known one with another method a405; neither reaches Bot Protection. A body past its cap is a413. The requests under the path are never judged and never reach your handler; every other path is judged as before. - What it sends. The browser's
User-Agent,Accept,Accept-Language,Content-Type,Origin,Sec-Fetch-*,Sec-CH-*andIf-None-Match, your key as the bearer, the visitor's address inKorzen-Visitor-Ip(read byip, as a decision reads it) and the path inKorzen-Forwarded-Prefix. Never aCookie, the browser'sAuthorizationor anX-Forwarded-*. What comes back keeps Korzen's caching and security headers as Korzen wrote them, never aSet-Cookie; a redirect is passed back, never followed, and an answer Korzen sent without aContent-Typegoes back without one (the middleware clears PHP'sdefault_mimetypefor that request). - The challenge page. A challenged page load goes to
/kz/challenge?c=…&r=…on your domain instead ofkorzen.dev, so a visitor who cannot reachkorzen.devstill solves it. - The path.
firstPartyPathnames another one, for example once a block list learns/kz: it starts with/, ends without one, and is at most 64 characters ofA-Z a-z 0-9 . _ ~ -segments. One that does not read is logged once, and the mode stays off. - The transport. The forwards go through the client's transport:
CurlTransportandStreamTransportimplementForwardTransporttoo. ATransportof yours implements it as well, or the mode stays off, logged once. - The body, a limit. PHP runs your script only once the server in front has the whole
body, so the forwarder never sees a body that trickles or never ends, nor a chunked one
past the cap that is still arriving. The two conformance cases that send those,
first-party-beacon-slow-bodyandfirst-party-beacon-past-the-cap-chunked, are skipped for PHP. Bound them in the server in front instead: nginx'sclient_body_timeoutandclient_max_body_size(64k is enough for/kz). A chunked body that ends is read and capped as usual (413). - Fail open. Bot Protection slow (past 1 s for a GET, 2.5 s for a POST), down or
answering garbage, no visitor address, no key, an error in the forwarder: a
503with an empty body andCache-Control: no-store. The tag drops that signal and the page goes on; your other routes wait on nothing. Bot Protection's own4xxand5xxpass through. A visitor's address on your own network (a proxy's) is logged once. - How to tell it works. In the browser's network tab, the tag's requests go to your
own
/kz/tag.js,/kz/n,/kz/pingand/kz/s, none tokorzen.dev;/kz/pinganswers204.
Supported versions
PHP 8.2, 8.3, 8.4 and 8.5, the releases the PHP project supports. The call goes through PHP's stream wrapper: https needs the openssl extension, which a PHP that serves https has.
The guide: https://korzen.dev/bot-protection/docs/php.
Licence
Proprietary: you may use it only to integrate with Korzen while you hold an account. See the licence.