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.
| Setting | Default | What it does |
|---|---|---|
logToSyslog | on | Writes to syslog as craft(host)[pid]:, the shape fail2ban's common.conf already parses. |
syslogFacility | auth | auth, authpriv, daemon or user. auth is where the stock jails already look. |
logToFile | on | Appends the same line to a file, with a UTC timestamp. |
logFilePath | empty | Where that file is. Empty means storage/logs/fail2ban.log. Environment variables work. |
logToDatabase | on | Keeps a copy for the control panel. Enumeration detection and the built-in blocker count from it, so both stop working without it. |
ledgerRetentionDays | 30 | How long the database copy is kept. 0 is forever. |
logRequestDetails | on | Stores the user agent and referrer in the database copy. Never in the log lines. |
ident | empty | What 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.
| Setting | Default | What it does |
|---|---|---|
probePaths | 30-odd paths | Paths that only exist on software this site is not running. Matched as whole path segments. |
maxEventsPerRequest | 10 | How many 404s and probes one request may log. |
enumerationThreshold | 3 | Different usernames from one address before it counts as enumeration. Pro. |
enumerationWindow | 300 | The 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.
| Setting | Default | What it does |
|---|---|---|
trustProxyHeaders | off | Whether a forwarded header may name the client at all. |
trustedProxies | empty | The proxies allowed to set it. Exact addresses, 10.0.0. prefixes or CIDR ranges. |
proxyHeaders | X-Forwarded-For | Which header to read. Usually just the one your proxy sets. |
ignoreIps | 127.0.0.1/8, ::1 | Never 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
| Setting | Default | What it does |
|---|---|---|
pseudonymizeUsernames | off | Replaces 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
| Setting | Default | What it does |
|---|---|---|
blockedUsernames | admin, administrator, root, test, user, craft | Exact names, case-insensitive. |
blockedUsernamePatterns | empty | Regular expressions, without delimiters, case-insensitive. |
enforceBlockedUsernames | off | Refuse 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:
| Jail | Default | Why |
|---|---|---|
craft-hard | 1 in 1 hour → 24-hour ban | Things nobody does by accident. |
craft-auth | 5 in 10 minutes → 1-hour ban | A colleague who forgot their password gets four goes. A dictionary gets five. |
craft-probe | 2 in 1 hour → 24-hour ban | Scanners reading down a list. Pro. |
craft-flood | 60 in 5 minutes → 1-hour ban, off | 404s 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.
| Setting | Default | What it does |
|---|---|---|
jails | the table above | Per jail: enabled, maxRetry, findTime and banTime, in seconds. |
jailAction | iptables-multiport | The fail2ban banaction. A name from action.d. |
jailPorts | http,https | The 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.
| Setting | Default | What it does |
|---|---|---|
internalBlocking | off | Turns it on. Needs logToDatabase, which is what it counts. |
banStatusCode | 403 | The status a banned request gets. |
banMessage | "Your IP address has been temporarily blocked…" | Plain text shown on the built-in page. |
banTemplate | empty | A site template rendered instead of the built-in page. |
ignoreLoggedIn | on | Never block a signed-in user with control panel access. |
notifyEmail | empty | Where 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
| Permission | Allows |
|---|---|
| View security events | The overview and the events list. |
| ↳ Block and release addresses | Banning, releasing and forgetting addresses, and clearing events. |
Setup and Settings are admin-only.