- Shell 59.3%
- PHP 40.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .githooks | ||
| bin | ||
| dist | ||
| src | ||
| tests/phpstan | ||
| .gitignore | ||
| build.sh | ||
| CHANGELOG.md | ||
| cliff.toml | ||
| phpcs.xml | ||
| phpstan.neon | ||
| README.md | ||
| updates.xml | ||
Fail2ban for Joomla
Joomla 5/6 package that logs failed login attempts in the administrator, and optionally on the frontend, to a file, so fail2ban can block the IP address.
2026-11-12 13:45:02 +01:00 LOGINFAIL ip=1.2.3.4 user=admin
2026-11-12 13:45:09 +01:00 MFAFAIL ip=1.2.3.4 user=admin
2026-11-12 13:46:10 +01:00 SITELOGINFAIL ip=1.2.3.4 user=jan
LOGINFAIL: wrong password or unknown user (onUserLoginFailure), a correct password for a blocked/expired account (onUserAuthorisationFailure), or a failed passkey login ("Log in with a passkey", plg_system_webauthn). For a passkey login the username is the one the passkey was requested for, or-when the request named no existing user.MFAFAIL: wrong Multi-factor Authentication code, also after the try limit has been reached. This includes a passkey used as the second factor whose signature does not verify.- Failed logins on the frontend (website) are only logged when the option Log frontend logins is on
(Plugins → User - Fail2ban, tab Plugin; off by default). They get the prefix
SITE:SITELOGINFAILandSITEMFAFAIL, so the log shows where an attempt was made. The same jail blocks them; visitors who mistype their password 5 times within 10 minutes are then blocked as well. A filter set up with version 1.3 or older does not recognise these lines: run step 1 of the Server setup tab again (or copy the filter again). - The timestamp includes the UTC offset, so fail2ban is never confused by a PHP timezone that differs from the server's timezone.
Package contents
| Extension | Purpose |
|---|---|
plg_user_fail2ban |
Listens to the events and writes the log lines. Holds the settings. |
plg_system_fail2ban |
Helper plugin, required for logging wrong MFA codes (see below). |
On a fresh install both plugins are enabled automatically.
Why a system plugin?
A wrong MFA code does not trigger onUserLoginFailure: the password login has already succeeded
at that point. Joomla reports the wrong code with onComUsersCaptiveValidateFailed (and, after the
try limit, with onComUsersCaptiveValidateTryLimitReached). Joomla does not import the user plugins
in that request, so a user plugin never receives those events. The system plugin therefore imports
only plg_user_fail2ban on com_users/captive.* requests in the administrator. All logging stays
in the user plugin.
Installation
- Download
pkg_fail2ban-<version>.zipfrom the latest release and install it via System → Install → Extensions. Both plugins are enabled, and a message links to the next step. - Set up fail2ban on the server (see Server setup): the plugin only writes the log file, fail2ban does the blocking.
- Optional: set a different log file path on the tab Plugin of Plugins → User - Fail2ban.
The default is
<folder above the Joomla root>/logs/joomla-fail2ban.log, i.e. outside the webroot. The folder is created if it does not exist, and the file gets mode0640. If writing fails, the login continues as normal and a warning is written toadministrator/logs/plg_user_fail2ban.php.
Updates
The package has an update server: Joomla shows new releases under System → Update → Extensions
and installs them from there. Joomla reads updates.xml from the branch main of this repository and
downloads the zip from dist/ on main, checking its SHA-256. Version 1.0.0 was released without the
update server: install the next version once by hand, after that updates arrive automatically.
IP address and reverse proxies
The IP address comes from Joomla (IpHelper::getIp()). X-Forwarded-For is only trusted when
Behind Load Balancer (Global Configuration → Server) is enabled. Otherwise REMOTE_ADDR is
used, which cannot be spoofed.
- If the site runs behind a reverse proxy (e.g. nginx → Apache, or a load balancer), enable that option. Otherwise the plugin logs the proxy's IP and fail2ban bans the proxy.
- If the site is behind Cloudflare or another CDN, a firewall ban on the server does not help: the traffic comes from the CDN. In that case use a fail2ban action that bans through the CDN's API.
Server setup
fail2ban runs on the server and reads the log file of the plugin. Set it up once per site, as a user
that may use sudo (for example via SSH). fail2ban itself must be installed:
- Debian or Ubuntu:
sudo apt install fail2ban - AlmaLinux, Rocky Linux or RHEL:
sudo dnf install epel-release, thensudo dnf install fail2ban
The easy way: the Server setup tab
Open System → Plugins → User - Fail2ban in the Joomla administrator and go to the tab Server setup. It shows four ready-to-copy command blocks, already filled in for this site:
- Filter: writes
/etc/fail2ban/filter.d/joomla-admin.conf, which tells fail2ban how to read the log lines. All Joomla sites on the server share it. - Jail: writes
/etc/fail2ban/jail.d/<jail>.conf, the jail of this site, with the log file of the site and your current IP address inignoreip(when it is a public address), so you cannot lock yourself out. It blocks an IP address for 1 hour after 5 failed logins within 10 minutes (maxretry = 5,findtime = 10m,bantime = 1h); repeat offenders are blocked longer. - Activate and check: starts fail2ban when it is not running yet (
systemctl enable --now),fail2ban-client reload, the status of the jail, andfail2ban-regexto show how many log lines the filter recognises. - Log rotation (optional): writes
/etc/logrotate.d/<jail>with the PHP user of the site as the owner.
Copy each block with its button and paste it as a whole in the terminal on the server. The tab also warns when the log file does not exist yet (fail2ban only starts the jail when it does: log in once with a wrong password first), when the log file is inside the webroot, and when Joomla sees a private IP address (see IP address and reverse proxies).
More sites on one server
Every Joomla site gets its own jail and logrotate file, named joomla-<folder>-<hash> (for example
joomla-example-co-3f2a): up to 10 characters of the folder name of the site and 4 characters of a
hash of its log file. Run the steps on every site; nothing of another site is overwritten. A block
applies to all websites on the server, because the firewall blocks the ports 80 and 443. The name has
at most 22 characters, because iptables refuses longer chain names (f2b-<jail>, 28 at most).
Servers with a proxy, a CDN or a control panel
- A proxy or cache in front of PHP (for example Varnish on CloudPanel, or nginx in front of Apache on
Plesk or cPanel) can make PHP see
127.0.0.1instead of the visitor. Check theip=values in the log file: if they are an address of the server, enable Behind Load Balancer in Joomla (see IP address and reverse proxies).127.0.0.1is inignoreip, so otherwise nobody is ever blocked. - Cloudflare or another CDN: a block in the firewall of the server does not help, because the traffic comes from the CDN. If the log file shows addresses of the CDN itself, do not use the jail: it would block the CDN and with it regular visitors. Use a fail2ban action that blocks through the API of the CDN instead.
- Control panels with their own fail2ban screen may not show this jail; it works nonetheless.
- Shared hosting without root access cannot run fail2ban; the plugin then only logs.
By hand
The configuration files are in the plugin folder on the server, after installation:
plugins/user/fail2ban/server/ in the Joomla root (in this repository:
src/plg_user_fail2ban/server/). For one site, replace /path/to/joomla with its Joomla root:
sudo cp /path/to/joomla/plugins/user/fail2ban/server/fail2ban/filter.d/joomla-admin.conf /etc/fail2ban/filter.d/
sudo cp /path/to/joomla/plugins/user/fail2ban/server/fail2ban/jail.d/joomla-admin.conf /etc/fail2ban/jail.d/
sudo cp /path/to/joomla/plugins/user/fail2ban/server/logrotate/joomla-fail2ban /etc/logrotate.d/
Then edit /etc/fail2ban/jail.d/joomla-admin.conf: set logpath to the log file of the site and add
your own addresses to ignoreip. In /etc/logrotate.d/joomla-fail2ban, set the path and the owner
(the PHP user, twice). For more sites, give every site its own copy of both files with another name,
and in the jail file another section name ([joomla-admin]). Check and activate:
sudo fail2ban-regex /path/to/logs/joomla-fail2ban.log /etc/fail2ban/filter.d/joomla-admin.conf
sudo fail2ban-client reload && sudo fail2ban-client status joomla-admin
Development
src/
├── pkg_fail2ban/ package manifest, install script, language files
├── plg_user_fail2ban/ user plugin (logic, settings, Server setup tab), en-GB + nl-NL
│ └── server/ fail2ban filter.d and jail.d, logrotate (shown on the Server setup tab)
└── plg_system_fail2ban/ system plugin (MFA helper), en-GB + nl-NL
tests/phpstan/ PHPStan bootstrap (Joomla constants)
.githooks/commit-msg checks the commit message convention
.githooks/pre-push refuses every push to main
bin/check.sh code checks: lint, xml, phpcs (phpcs.xml), phpstan (phpstan.neon)
bin/changelog.sh release notes and CHANGELOG.md with git-cliff (cliff.toml)
bin/release.sh makes a release: develop -> main, tag, release on Forgejo
bin/updates-xml.sh adds a release to updates.xml (the Joomla update server)
updates.xml the Joomla update server, read from main
bin/forgejo.sh issues, labels, pull requests and releases through the Forgejo API
build.sh test build: build/pkg_fail2ban-<version>-dev.zip (never published);
a release (--release) writes dist/pkg_fail2ban-<version>.zip
Branches
develop: all development happens here.main: only receives releases. Never push to main.bin/release.shmerges develop into main through a pull request on Forgejo. Forgejo refuses direct pushes to main (branch protection), and thepre-pushhook refuses them locally before anything is sent.
Code checks
bin/check.sh
Runs php -l (PHP 8.5), checks that every XML file is well-formed, checks the Joomla 6 coding
standard with phpcs (PSR-12 with Joomla's line length, phpcs.xml) and runs PHPStan level 5
(phpstan.neon) for PHP 8.1 up to 8.5. PHPStan analyses against the Joomla sources of the local DDEV
site; set JOOMLA_ROOT to use another Joomla site. phpcs and PHPStan are downloaded once to
build/tools (not in git), pinned on version and SHA-256. Run it before every commit; it has to be
clean. Run only some checks with, for example, bin/check.sh phpcs phpstan.
Contributing
Install the hooks (commit message, no push to main) once per checkout:
git config core.hooksPath .githooks
The first line of a commit message is <type>: <description> (#<issue>), for example
feat: Log failed logins on the frontend as well (#12) or fix: Log file is created with mode 0640 (#7).
- Types:
feat(new for the user),fix(a fault solved),perf,refactor,docs,test,chore,build,ci,style. A breaking change gets a!(feat!: …). - The first line is a line in the release notes: write it for the reader, as a sentence that stands on its own. Details go in the body.
- Release commits start with
chore(release):and need no issue number. - The hook refuses a commit without type or issue number. Skip it once with
git commit --no-verify.
Releases
The release notes are put together from the commit messages with git-cliff
(cliff.toml, bin/changelog.sh). Only commits that change something in src/ count. They are
grouped on their type (New, Fixed, Improved, Changed, Documentation, Maintenance), with the issue
number first, as a link. git-cliff is downloaded once to build/tools (not in git), pinned on version
and SHA-256.
Show the changes since the last release:
bin/changelog.sh
Make a release, on a clean develop:
bin/release.sh 1.0.1
- Runs
bin/check.sh; the release stops when a check fails. - Runs
./build.sh --release 1.0.1: the version in all manifests, the draft of the release notes inbuild/release-notes-1.0.1.md,CHANGELOG.mdgenerated again anddist/pkg_fail2ban-1.0.1.zip;bin/updates-xml.shadds 1.0.1 with the SHA-256 of the zip toupdates.xml. - Commits
chore(release): 1.0.1on develop and pushes develop. - Opens the pull request develop → main on Forgejo and merges it (merge commit). From this moment
Joomla sites see the update:
updates.xmland the zip arrive on main in the same merge. - Tags the merge commit on main
v1.0.1and pushes the tag. - Creates the release
v1.0.1on Forgejo (titleRelease v1.0.1) with the release notes as the description, attaches the zip and verifies the upload. - Fast-forwards the local main to origin/main.
When the script stops halfway (for example a network error), run it again with the same version: what is already done is skipped. Add what to look out for when updating to the description of the release on Forgejo afterwards, when needed.
bin/forgejo.sh (from rkwevents) talks to the Forgejo API with the token of the tea login for
https://code.kreijveld.com; bin/forgejo.sh --help shows all commands.
Never edit CHANGELOG.md by hand; improve the commit message instead.
Tested with Joomla 6.2.0 (PHP 8.5, DDEV) and fail2ban 1.1.0.
© 2026 René Kreijveld Webdevelopment, GNU GPL version 2 or later.