Joomla package that logs failed administrator logins (incl. MFA) for fail2ban
  • Shell 59.3%
  • PHP 40.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-13 12:31:02 +00:00
.githooks build: develop branch workflow with automated release to main 2026-11-12 15:13:32 +01:00
bin feat: Update server, so Joomla shows new releases under System - Update - Extensions 2026-11-12 15:51:56 +01:00
dist chore(release): 1.4.0 2026-11-13 13:20:28 +01:00
src chore(release): 1.4.0 2026-11-13 13:20:28 +01:00
tests/phpstan build: Code checks with phpcs and PHPStan 2026-11-12 14:53:28 +01:00
.gitignore build: Release notes with git-cliff and commit-msg hook 2026-11-12 14:45:41 +01:00
build.sh feat: Server setup tab with ready-to-copy commands for fail2ban and logrotate 2026-11-12 16:53:39 +01:00
CHANGELOG.md chore(release): 1.4.0 2026-11-13 13:20:28 +01:00
cliff.toml build: Release notes with git-cliff and commit-msg hook 2026-11-12 14:45:41 +01:00
phpcs.xml build: Code checks with phpcs and PHPStan 2026-11-12 14:53:28 +01:00
phpstan.neon build: Code checks with phpcs and PHPStan 2026-11-12 14:53:28 +01:00
README.md fix: removed unneeded part in README #000 2026-11-13 13:29:33 +01:00
updates.xml chore(release): 1.4.0 2026-11-13 13:20:28 +01:00

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: SITELOGINFAIL and SITEMFAFAIL, 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

  1. Download pkg_fail2ban-<version>.zip from the latest release and install it via System → Install → Extensions. Both plugins are enabled, and a message links to the next step.
  2. Set up fail2ban on the server (see Server setup): the plugin only writes the log file, fail2ban does the blocking.
  3. 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 mode 0640. If writing fails, the login continues as normal and a warning is written to administrator/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, then sudo 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:

  1. 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.
  2. 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 in ignoreip (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.
  3. Activate and check: starts fail2ban when it is not running yet (systemctl enable --now), fail2ban-client reload, the status of the jail, and fail2ban-regex to show how many log lines the filter recognises.
  4. 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.1 instead of the visitor. Check the ip= 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.1 is in ignoreip, 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.sh merges develop into main through a pull request on Forgejo. Forgejo refuses direct pushes to main (branch protection), and the pre-push hook 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
  1. Runs bin/check.sh; the release stops when a check fails.
  2. Runs ./build.sh --release 1.0.1: the version in all manifests, the draft of the release notes in build/release-notes-1.0.1.md, CHANGELOG.md generated again and dist/pkg_fail2ban-1.0.1.zip; bin/updates-xml.sh adds 1.0.1 with the SHA-256 of the zip to updates.xml.
  3. Commits chore(release): 1.0.1 on develop and pushes develop.
  4. Opens the pull request develop → main on Forgejo and merges it (merge commit). From this moment Joomla sites see the update: updates.xml and the zip arrive on main in the same merge.
  5. Tags the merge commit on main v1.0.1 and pushes the tag.
  6. Creates the release v1.0.1 on Forgejo (title Release v1.0.1) with the release notes as the description, attaches the zip and verifies the upload.
  7. 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.