justinholtweb / craft-fail2ban
Turns Craft's own security events into log lines fail2ban can ban on — failed logins, blocked usernames, bad tokens, 404 floods and WordPress probes — and writes the filter and jail files for you.
Package info
github.com/justinholtweb/craft-fail2ban
Type:craft-plugin
pkg:composer/justinholtweb/craft-fail2ban
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- craftcms/cms: ^5.3.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-29 12:48:30 UTC
README
Your web server log shows a 200 for a wrong password and a 200 for a right one. fail2ban is
very good at banning addresses and has no way to tell those apart. Craft knows the difference.
This plugin says it out loud — in the format fail2ban already understands — and lets the firewall do the banning, at the edge, before PHP is involved at all.
2026-08-18T09:14:02+0000 craft(example.com)[4821]: Authentication failure for jo@example.com from 203.0.113.7
2026-08-18T09:14:04+0000 craft(example.com)[4823]: Authentication attempt for unknown user root from 203.0.113.7
2026-08-18T09:14:06+0000 craft(example.com)[4826]: Blocked authentication attempt for admin from 203.0.113.7
2026-08-18T09:14:09+0000 craft(example.com)[4831]: Probe for /wp-login.php from 203.0.113.7
It is the Craft equivalent of wp-fail2ban, including
the log-line convention — craft(host)[pid]: is the shape fail2ban's own common.conf already
parses, and it is deliberately the same shape wp(host)[pid]: has, so an admin who runs both
knows what these lines are before reading a word of documentation.
Requirements
Craft CMS 5.3+ and PHP 8.2+. No runtime dependencies, no build step, no outbound requests.
fail2ban itself is optional. See Without fail2ban.
Installation
composer require justinholtweb/craft-fail2ban php craft plugin/install fail2ban
Setting it up
-
Settings → Plugins → Fail2Ban. The defaults are working defaults: every authentication event that matters is on, lines go to the
authsyslog facility and tostorage/logs/fail2ban.log, and the client address is taken from the socket. -
Fail2Ban → Setup. Copy the generated files onto the machine running fail2ban:
sudo cp craft-*.conf /etc/fail2ban/filter.d/ sudo cp craft.local /etc/fail2ban/jail.d/ sudo fail2ban-client reloadWith shell access on the Craft server,
sudo php craft fail2ban/configs/writedoes the same thing. -
Prove it works, before you need it to:
php craft fail2ban/events/test # writes one line per enabled event type fail2ban-regex /path/to/storage/logs/fail2ban.log /etc/fail2ban/filter.d/craft-auth.conf php craft fail2ban/status # one screen: channels, events, jails, filter agreement
What it logs
Every event carries a jail, or none. The jail is what a ban is counted against.
Lite
| Event | Jail | What it means |
|---|---|---|
auth.failure |
craft-auth |
The name matched an account; the password did not. |
auth.unknownUser |
craft-auth |
Nothing matched the name — the shape of credential stuffing. |
auth.blockedUser |
craft-hard |
A username on your blocked list. No innocent version of this. |
auth.cooldown |
craft-auth |
The account is already in Craft's cooldown and it has not stopped. |
auth.locked |
craft-auth |
Craft locked the account after too many failures. |
auth.suspended |
craft-auth |
A suspended account is still being tried. |
auth.elevatedFailure |
craft-auth |
A signed-in session failed the password check for a sensitive action. |
auth.invalidToken |
craft-hard |
A reset or verification link followed with a token that does not verify. |
auth.success |
— | Not an attack. Logged so you can see who got in after the ones who did not. |
auth.logout |
— | Off by default. |
auth.passwordReset |
— | A reset email was requested. In bulk it is harassment, not a break-in. |
Pro
| Event | Jail | What it means |
|---|---|---|
auth.enumeration |
craft-hard |
One address tried several different usernames inside the window. |
path.probe |
craft-probe |
A request for a path that only exists on software you are not running. |
http.notFound |
craft-flood |
One 404 is a typo. Two hundred is a directory scan. |
http.forbidden |
craft-flood |
Something was refused. |
http.badRequest |
craft-flood |
Off by default: a missing CSRF token is usually a stale tab. |
gql.failure |
craft-auth |
A GraphQL request with a token that buys it nothing. |
user.registered |
— | Off by default. On a site with public registration, a spike is the story. |
path.probe is the one with no WordPress equivalent, for the obvious reason: on a Craft site,
/wp-login.php, /xmlrpc.php, /.env and /vendor/phpunit are requests nobody makes by
accident. The default list has thirty-odd of them, and paths are matched as whole segments, so
/news/how-we-left-wp-admin-behind is somebody reading an article.
The jails
| Jail | Default | Why |
|---|---|---|
craft-hard |
1 in 1h → 24h ban | Things nobody does by accident. One strike is plenty. |
craft-auth |
5 in 10m → 1h ban | A colleague who has forgotten their password gets four goes; a dictionary gets five. |
craft-probe |
2 in 1h → 24h ban | Scanners reading down a list. |
craft-flood |
60 in 5m → 1h ban, off | 404s have innocent causes — a stale link, a broken image, a feed reader. |
The numbers live in the plugin's settings and are written into both the generated jail.local
and the built-in blocker, so the firewall and the plugin cannot come to different conclusions
about the same traffic.
Blocked usernames
Craft has never shipped an account called admin, so a login offering that name did not come from
anyone who has ever seen your site — it came from a list.
There are two readings of a list like that, and the difference between them is a lockout:
- "These names do not exist here." The default. The event fires only when nothing matched the
name, so an install whose administrator genuinely is called
adminis never touched by theadminentry. - "These names must never sign in."
enforceBlockedUsernames, off until you turn it on. A refused login fails exactly the way a wrong password fails, so nothing is confirmed to whoever is guessing. The settings screen names every real account the switch would shut out before you throw it.
Who is asking
Whatever address this plugin names in a log line can end up dropped at your firewall. So:
Proxy headers are not trusted by default. X-Forwarded-For is a string the client picked, and
a plugin that logged it out of the box would hand anyone on the internet a way to get an address of
their choosing banned — your office, your monitoring, Googlebot — by typing it into a header.
Turn on Trust forwarded-for headers only if the site genuinely sits behind a proxy you control, and then list the proxies. The chain is then read right to left, discarding your own hops and stopping at the first address that is not one of them.
ignoreIps is exempt from logging and from banning, and is written into the generated jail as
ignoreip, so the plugin and fail2ban agree about who is exempt rather than each keeping a list.
Privacy
Turn on Hash usernames in the log and the log channels get a stable, keyed, one-way hash
instead of a name. A jail can still count five failures against one account; /var/log stops being
a directory of your customers' email addresses. The control panel keeps the real name.
Query-string values are never logged — only the parameter names — because a scanner's query string is frequently the payload, and a live password-reset token is frequently in it.
Without fail2ban
Plenty of Craft sites live on hosting with no shell, where the log this plugin writes has no
reader. Pro's built-in blocker enforces the same jails in PHP: one indexed lookup at
EVENT_BEFORE_REQUEST, and a 403 for anything already banned.
Leave it off wherever fail2ban can be run. A firewall drop costs the attacker a TCP timeout and costs you one iptables rule; this costs a database read on every request and still hands PHP-FPM a worker for every attempt.
Raising your own events
The interesting security events on a real site are not all Craft's.
{% do craft.fail2ban.log('auth.failure', { username: submittedEmail }) %}
They go through the same channels, into the same jails, with the same exemptions.
To filter or veto events in PHP:
use justinholtweb\fail2ban\services\Logger; use justinholtweb\fail2ban\events\SecurityEventEvent; Event::on(Logger::class, Logger::EVENT_BEFORE_LOG, function(SecurityEventEvent $event) { if ($event->securityEvent->uri === '/known-noisy-path') { $event->isValid = false; } });
Console
php craft fail2ban/status # channels, events, jails, filter agreement
php craft fail2ban/configs/show # print the generated files
php craft fail2ban/configs/write --directory=/etc/fail2ban
php craft fail2ban/configs/verify # non-zero if a filter no longer matches
php craft fail2ban/events/test # one line per enabled event type
php craft fail2ban/events/list --hours=24 --type=auth.failure
php craft fail2ban/events/prune
php craft fail2ban/bans/list
php craft fail2ban/bans/add 203.0.113.7 --seconds=86400
php craft fail2ban/bans/remove 203.0.113.7
Why the filters are generated
Every hand-written fail2ban integration ends up in the same state: the application changed a log line, the filter did not, and because a jail that matches nothing looks exactly like a site nobody is attacking, it is discovered months later.
Here there is nothing to keep in step. Each event type owns one template —
'Authentication failure for {user} from {ip}' — and that single string renders the log line and
compiles the failregex. php craft fail2ban/configs/verify proves it, by generating a line for
every event type and matching the generated pattern against it. Put it in CI if you like.
Editions
Lite is the whole authentication story: every event in the first table, all three channels, the blocked-username list, the generated filters and jails, the verification, and the console commands.
Pro adds user enumeration, path probes, HTTP status logging, GraphQL failures, registrations, the built-in blocker and ban notifications.
Licence
Commercial, one licence per Craft installation. See LICENSE.md.