Symfony
antibot/symfony is the PHP guard as a Symfony bundle. It
assesses the requests your application protects, on the kernel's request event,
and acts on the answer before your controller or your firewall runs: a refusal
is the response, and the controller never sees the request. A sign-in's outcome
is read from your own response at kernel.terminate, so you rarely need to
report one yourself.
It needs PHP 8.2 or later and Symfony 7.4 (the current LTS) or 8.x. Its tests run against Symfony 7.4 on PHP 8.3 and Symfony 8.1 on PHP 8.4.
Install
composer require antibot/symfony
Register the bundle in config/bundles.php:
return [
// ...
Antibot\Symfony\AntibotBundle::class => ['all' => true],
];
Add your key to .env:
ANTIBOT_SECRET_KEY=... # a key that may assess and nothing else
Nothing is assessed until the bundle is registered. Without ANTIBOT_SECRET_KEY
it does nothing and logs one warning through your PSR-3 logger, so a missing key
never takes the site down.
Configure
Every setting has a default, so an application that sets only the key needs no
file. To change anything, create config/packages/antibot.yaml:
antibot:
# The key is read from ANTIBOT_SECRET_KEY by default; say it here only to override.
# session_keys: the list of your session keys, if you have them: receipts then verify without a call.
# Your sign-in forms: the path, and the account field as the request carries it.
logins:
- { path: /login, field: _username } # Symfony's form_login default: change it to your field
- { path: /api/login, field: 'user[email]' }
protect: ['/shop', '/api'] # path prefixes; omit to protect every path
The full set, with its defaults:
| Setting | Default | |
|---|---|---|
secret_key |
%env(default::ANTIBOT_SECRET_KEY)%, empty: requests pass, one warning logged |
The key that may assess |
endpoint |
https://api.antibot.dev |
|
timeout |
2 |
Seconds to wait for an assessment before the request goes on unassessed |
session_keys |
[]: every request is assessed |
Session keys that verify a receipt without a call |
protect |
null: every path |
Path prefixes to protect; configured forms are always covered |
missing |
allow |
challenge interstitials a page navigation without a receipt |
step_up |
off |
hold puts a press-and-hold before a page navigation whose receipt the verdict asked to step up (the edge's step-up); hold-all refuses its API calls too until the hold is passed; needs challenge |
agent_on_pages |
false |
|
max_score |
70 |
A receipt scored at or above this is refused |
monitor |
false |
Monitor mode: decide everything, act on nothing; see below |
block_signals |
null: bot |
Signals that condemn a receipt whatever its score; [] for none |
cookie |
_abid |
The cookie a receipt arrives in |
token_header |
antibot-token |
The header a receipt may arrive in instead; '' turns it off |
flows |
{} |
The steps of your flows by name, each "[METHOD ]path[@minMs]" in order: checkout: ["POST /cart/checkout", "/checkout/delivery", "/checkout/payment@3000"] (steps taken faster than a person) |
graphql, graphql_persisted |
[], {} |
Your GraphQL endpoints (["/graphql"]), and your trusted documents, id to text (GraphQL: one request that is many) |
session_cookie |
null |
Your session cookie by name (PHPSESSID, or your framework.session.name): sent as a digest, never the cookie, so a signed-in session is bound to the device and browser that first used it (a signed-in session, bound to its device) |
bound_paths |
[] |
Prefixes only your pages call: there only a receipt bound to the page's key counts, with a fresh signature by it (API calls only your pages can make). Needs session_keys |
origin |
the request's own | The site's origin, when TLS ends upstream and the proxy is not configured |
logins |
[{path: /login, field: _username}] |
Sign-in forms: path and field, a field null is assessed and never reported |
codes |
[] |
Gift card, voucher and coupon redemptions: path and field |
contents |
[] |
Comment, review and contact forms: path and field |
challenge |
off | public_key (from ANTIBOT_PUBLIC_KEY, if you set it), agent_url, endpoint: the interstitial's settings |
A sign-in's account is the field's value, read the way the request carries it:
_username is a form field, user[email] the same field nested, in a form or in
JSON, and user.email the JSON path. A multipart form is read the same way. A
JSON number is read as an account or a code only when it is a whole number within
2^53 − 1 (9007199254740991); a bigger one, or one with a fraction, is not read.
Paths are the ones your routes match. Behind a front controller
(/index.php/login) or in a subdirectory (/myapp/login), the guard reads
/login, the path the router matched, as well as the URL the client sent: the
entries above and protect apply to the same requests they apply to in your
routes file, and a path written whole (/myapp/login) does too. The assessed
URL, and a phone's signed request, are the one the client sent. The query string
is given as the client sent it, so a field carried there is read too.
Trusted proxies
The visitor's address is $request->getClientIp(), which Symfony reads through
framework.trusted_proxies. Behind a load balancer, CDN or reverse proxy, name
that proxy and nothing else, in config/packages/framework.yaml:
framework:
trusted_proxies: '10.0.0.10' # your load balancer's address
trusted_headers:
- x-forwarded-for
- x-forwarded-host
- x-forwarded-port
- x-forwarded-proto
Never trust every address on an application a client can reach directly: then any client can write the forwarded address the assessment believes. Without trusted proxies the address is the connection's, and a forwarded header is ignored.
Sign-ins
The guard reads a sign-in's outcome from the response, once it has been sent:
- a redirect back to the form, or a 401 or 422, is a failure;
- any other redirect, or a JSON 2xx, is a success.
Symfony's form_login fails by redirecting back to the login path and succeeds
by redirecting elsewhere, so it needs nothing from you once the logins entry
names the form's field. The default field is _username, which is form_login's
default; if your form posts email, say so:
antibot:
logins:
- { path: /login, field: email }
Set field: null for a form whose redirect back to itself would read as a
failure against the account (a password reset request, say): it is still assessed
and challenged, and nothing is read or reported.
Telling the app what happened
When your code knows better than the redirect, say so, and the response is no longer inspected. The state is on the request:
use Antibot\GuardState;
use Symfony\Component\HttpFoundation\Request;
public function login(Request $request): Response
{
$state = $request->attributes->get('antibot'); // a GuardState, or null
$ok = $this->authenticate($request);
if ($state instanceof GuardState) {
$state->outcome($ok ? 'success' : 'failure');
}
return $ok ? $this->redirectToRoute('home') : $this->redirectToRoute('login');
}
With form_login the controller never runs, so report from the security event
instead: LoginSuccessEvent and LoginFailureEvent both carry the request, and
its antibot attribute is the same state.
outcome() does nothing on a request that is not a configured form. Only a
success is remembered as a device the account signs in from, so report
'success' only for a sign-in that succeeded.
What the controller sees
On a request the bundle passes on, $request->attributes->get('antibot') is the
Antibot\GuardState. It is null where the bundle did not run: no key, or a
request that was not a main request.
| Member | |
|---|---|
verdict |
The assessment: action, score, reasons, requestId; null where nothing was assessed (an unprotected path, or the API out of reach) |
receipt |
The verified receipt's claims, when the request carried one |
ip |
The visitor's address, as assessed |
field |
The form's account, as assessed |
outcome('success' | 'failure') |
Report how a sign-in went |
A refused request never reaches the controller or the firewall. The response is
the guard's: a 403 with error.code set to Blocked, or the interstitial for a
page navigation that must pass a challenge.
Monitor mode
Set monitor: true under antibot: to see what enforcing would do before it does. Every request is decided as usual, but nothing is refused or challenged: a request that would have been refused or sent to the interstitial reaches the controller, and the antibot request attribute's state has wouldAct set to ['action' => 'block' | 'challenge', 'reason' => …], the reason being what the guard would have answered with (signal:bot, assess:block, missing:challenge…). It is null when nothing would have been done. Sign-in outcomes are still reported, and every assessment is sent to the API marked as monitored, so the API keeps it as what would have been done, not as done. Use it for a site's first weeks, then set it back to false.
When the API is out of reach
A failed assessment never blocks your site. The request goes on unassessed, the
failure is written to your PSR-3 logger at warning, and the state's verdict
is null. A failed outcome report is logged the same way.
Development
The bundle's own tests are in integrations/symfony of the repository; see its
README.