Search by

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.

Maintainers

Package info

github.com/justinholtweb/craft-fail2ban

Type:craft-plugin

pkg:composer/justinholtweb/craft-fail2ban

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-29 12:47 UTC

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

  1. Settings → Plugins → Fail2Ban. The defaults are working defaults: every authentication event that matters is on, lines go to the auth syslog facility and to storage/logs/fail2ban.log, and the client address is taken from the socket.

  2. 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 reload

    With shell access on the Craft server, sudo php craft fail2ban/configs/write does the same thing.

  3. 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 admin is never touched by the admin entry.
  • "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.