Fail2Ban for Craft CMS

Configuration

Everything lives under Settings → Plugins → Fail2Ban. Any setting can also be fixed in config/fail2ban.php, which returns an array of the same names and takes precedence over the control panel:

<?php

return [
    'trustProxyHeaders' => true,
    'trustedProxies' => ['10.0.0.0/8'],
    'pseudonymizeUsernames' => true,
];

Values set in that file skip the settings screen's validation. The plugin re-checks anything that ends up in a generated fail2ban file, and names a blocked-username pattern that does not compile on Setup and in fail2ban/status, but it is worth running php craft fail2ban/status after changing it.

Channels

Every event can go to three places. Any combination works.

SettingDefaultWhat it does
logToSyslogonWrites to syslog as craft(host)[pid]:, the shape fail2ban's common.conf already parses.
syslogFacilityauthauth, authpriv, daemon or user. auth is where the stock jails already look.
logToFileonAppends the same line to a file, with a UTC timestamp.
logFilePathemptyWhere that file is. Empty means storage/logs/fail2ban.log. Environment variables work.
logToDatabaseonKeeps a copy for the control panel. Enumeration detection and the built-in blocker count from it, so both stop working without it.
ledgerRetentionDays30How long the database copy is kept. 0 is forever.
logRequestDetailsonStores the user agent and referrer in the database copy. Never in the log lines.
identemptyWhat goes inside craft(…). Empty means the request's host name.

Use the file channel if you can. The generated jails point at it whenever it is on, because it is the one path this plugin can state with certainty. Where syslog ends up is the host's business — /var/log/auth.log, /var/log/secure, the journal — and with the file channel off, the jail's logpath is a guess.

A line looks like this in both channels:

2026-08-18T09:14:02+0000 craft(example.com)[4821]: Authentication failure for jo@example.com from 203.0.113.7

Events

Each event type can be switched on or off individually. See Usage for the full list and which jail each one counts towards.

SettingDefaultWhat it does
probePaths30-odd pathsPaths that only exist on software this site is not running. Matched as whole path segments.
maxEventsPerRequest10How many 404s and probes one request may log.
enumerationThreshold3Different usernames from one address before it counts as enumeration. Pro.
enumerationWindow300The window, in seconds, that count is taken over. Pro.

Remove a path from probePaths if this site genuinely serves it — /cgi-bin on an old host, for example.

Client addresses

Whatever address the plugin names in a log line can end up dropped at your firewall, so where that address comes from matters more than any other setting.

SettingDefaultWhat it does
trustProxyHeadersoffWhether a forwarded header may name the client at all.
trustedProxiesemptyThe proxies allowed to set it. Exact addresses, 10.0.0. prefixes or CIDR ranges.
proxyHeadersX-Forwarded-ForWhich header to read. Usually just the one your proxy sets.
ignoreIps127.0.0.1/8, ::1Never logged as an offender and never banned. Written into the jail as ignoreip.

By default the address comes off the socket. X-Forwarded-For is a string the visitor chose. Trusting it by default would let anyone get an address of their choosing banned — your office, your monitoring, a search engine — by typing it into a header.

Behind a proxy, load balancer or CDN, that default is wrong in the other direction. Every request then arrives from the proxy's address, every event names the proxy, and the first attacker to trip a jail bans every visitor at once. The plugin watches for this: when an event arrives from a private address carrying a forwarded header, Setup and fail2ban/status say so under Fix these first.

To fix it, turn on trustProxyHeaders and list the proxy:

return [
    'trustProxyHeaders' => true,
    'trustedProxies' => ['10.0.0.0/8'],   // your load balancer's range
    'proxyHeaders' => ['X-Forwarded-For'],
];

A header is believed only when the connection itself comes from a trusted proxy. The chain is read right to left: your own proxies are skipped, the first address that is not one of them is the client, and a hop that does not parse ends the chain rather than being stepped over.

Name only the header your proxy actually writes. A proxy that sets X-Real-IP passes the visitor's own X-Forwarded-For through untouched, and listing a fallback such as CF-Connecting-IP means reading a header any visitor can send whenever the first one is empty.

Do not add a proxy to ignoreIps to make the warning go away. That exempts every visitor.

Privacy

SettingDefaultWhat it does
pseudonymizeUsernamesoffReplaces usernames in the log channels with a short keyed hash.

The hash is keyed on Craft's security key, stable, and one-way. A jail can still count five failures against one account, and /var/log stops being a list of your customers' email addresses. The control panel keeps the real name.

Query-string values are never logged, only parameter names, because a scanner's query string is often the payload and a live password-reset token is often in it.

Blocked usernames

SettingDefaultWhat it does
blockedUsernamesadmin, administrator, root, test, user, craftExact names, case-insensitive.
blockedUsernamePatternsemptyRegular expressions, without delimiters, case-insensitive.
enforceBlockedUsernamesoffRefuse a blocked name even when it belongs to a real account.

A login for a blocked name is logged at critical severity and counted against craft-hard, which bans on one strike.

With enforceBlockedUsernames off, the list means "these names do not exist here". The event fires only when no account matched, so an install whose administrator really is called admin is never touched by the admin entry.

With it on, the list means "these names must never sign in". A real account on the list is refused, and the refusal looks exactly like a wrong password, so nothing is confirmed to whoever is guessing. The settings screen lists every real account the switch would lock out before you turn it on. Rename those accounts first.

Patterns match anywhere in the name. test matches contest@example.com, and that is a one-strike ban for a real customer. Anchor them: ^test$, ^admin\d*$. The settings screen refuses a pattern that does not compile. One that arrives from config/fail2ban.php and does not compile matches nothing, is logged as a warning, and is named on Setup.

Jails

Four jails, each with its own numbers:

JailDefaultWhy
craft-hard1 in 1 hour → 24-hour banThings nobody does by accident.
craft-auth5 in 10 minutes → 1-hour banA colleague who forgot their password gets four goes. A dictionary gets five.
craft-probe2 in 1 hour → 24-hour banScanners reading down a list. Pro.
craft-flood60 in 5 minutes → 1-hour ban, off404s have innocent causes. Pro.

The same numbers are written into the generated jail.d/craft.local and used by the built-in blocker, so the firewall and the plugin cannot come to different conclusions about the same traffic.

SettingDefaultWhat it does
jailsthe table abovePer jail: enabled, maxRetry, findTime and banTime, in seconds.
jailActioniptables-multiportThe fail2ban banaction. A name from action.d.
jailPortshttp,httpsThe ports that action closes.
return [
    'jails' => [
        'craft-auth' => ['maxRetry' => 10, 'findTime' => 900, 'banTime' => 7200],
        'craft-flood' => ['enabled' => true],
    ],
    'jailAction' => 'nftables-multiport',
];

Anything left out of a jail falls back to its default, one key at a time.

The generated jail file is meant to be copied into /etc/fail2ban, and fail2ban runs its actions as root, so the action, ports, ignore list and numbers are restricted to the characters those values actually need. Regenerate after changing any of them.

The built-in blocker (Pro)

For hosts where fail2ban cannot run. The plugin enforces the same jails in PHP: one indexed lookup at the start of every request, and a refusal for anything already banned.

SettingDefaultWhat it does
internalBlockingoffTurns it on. Needs logToDatabase, which is what it counts.
banStatusCode403The status a banned request gets.
banMessage"Your IP address has been temporarily blocked…"Plain text shown on the built-in page.
banTemplateemptyA site template rendered instead of the built-in page.
ignoreLoggedInonNever block a signed-in user with control panel access.
notifyEmailemptyWhere to send one email per banned address.

Leave it off wherever fail2ban can run. A firewall drop costs an attacker a TCP timeout and costs you nothing. The blocker still spends a PHP worker and a database read on every attempt.

ignoreLoggedIn covers control panel users only. Front-end accounts are not exempt, because on a site with public registration an account costs an attacker nothing. The same rule decides whose 404s are logged.

A banTemplate receives ip, message, statusCode, expires (null for a permanent ban) and jail. If it fails to render, the built-in page is served instead.

notifyEmail sends one message per address, ever — not one per ban — so a persistent scanner does not turn into a persistent inbox.

Permissions

PermissionAllows
View security eventsThe overview and the events list.
↳ Block and release addressesBanning, releasing and forgetting addresses, and clearing events.

Setup and Settings are admin-only.