Guides

Protect SSH with fail2ban

A complete sshd jail you can paste, plus the four things that break it in practice: the wrong port, the wrong backend, the wrong mode and an old filter that can't read OpenSSH 9.8+ logs.

Updated 18 min readChecked against fail2ban 1.1.1

To protect SSH with fail2ban, enable the sshd jail in /etc/fail2ban/jail.local with enabled = true, set port to the port sshd really listens on (for example port = 2222), use backend = systemd on distributions without /var/log/auth.log, and run sudo fail2ban-client reload. Check it with sudo fail2ban-client status sshd. The defaults ban an IP for 10 minutes after 5 failures in 10 minutes.

/etc/fail2ban/jail.local
[sshd]
enabled  = true
port     = ssh
backend  = systemd
maxretry = 5
findtime = 10m
bantime  = 1h
bash
sudo fail2ban-client reload
sudo fail2ban-client status sshd

The rest of this page explains each line, covers custom ports, key-only servers and IPv6, and shows how to prove the jail actually catches attacks.

The stock sshd jail#

fail2ban ships an [sshd] jail in /etc/fail2ban/jail.conf. Upstream 1.1.1 defines it as:

/etc/fail2ban/jail.conf (excerpt)
[sshd]
#mode   = normal
port    = ssh
logpath = %(sshd_log)s
backend = %(sshd_backend)s

%(sshd_log)s and %(sshd_backend)s come from the paths-*.conf file for your distribution: /var/log/auth.log on Debian and Ubuntu, /var/log/secure on Fedora and RHEL, and the systemd journal wherever the packager switched sshd_backend to systemd. Don't edit jail.conf — package upgrades overwrite it. Put your changes in jail.local or a file in jail.d/, which are read later and win (config load order explained).

Most packages already enable the jail. Check what yours does before adding your own:

bash
cat /etc/fail2ban/jail.d/defaults-debian.conf   # Debian, Ubuntu
ls /etc/fail2ban/jail.d/                        # Fedora, EPEL: 00-firewalld.conf, 00-systemd.conf
Distribution fail2ban What the package sets for SSH
Debian 12 1.0.2 [sshd] enabled = true only, but there's no /var/log/auth.log (no rsyslog)
Debian 13 1.1.0 nftables, backend = systemd, journalmatch for ssh.service, enabled
Ubuntu 24.04 1.0.2 nftables, backend = systemd for all jails, [sshd] enabled = true
Fedora, RHEL 9/10 (EPEL) 1.1.0 firewalld actions; backend = systemd with the fail2ban-systemd package

Choose the right backend: log file or systemd journal#

fail2ban has to read sshd's log messages from somewhere. If it looks in the wrong place, the jail starts but never sees a failure — the most common reason for "fail2ban ssh not working".

  • Log file (backend = auto): reads /var/log/auth.log or /var/log/secure. Needs rsyslog writing that file.
  • Journal (backend = systemd): reads the systemd journal directly. Needs the Python systemd bindings (python3-systemd on Debian/Ubuntu, python3-systemd on Fedora/EPEL).

On Debian 12 there is no rsyslog by default, so /var/log/auth.log doesn't exist and fail2ban 1.0.2 refuses to start the jail with Have not found any log file for sshd jail. Fix it with the journal:

bash
sudo apt install python3-systemd
/etc/fail2ban/jail.local
[sshd]
enabled = true
backend = systemd

With the systemd backend, fail2ban-client status sshd shows the journal filter instead of a file list:

output
Journal matches:	_SYSTEMD_UNIT=sshd.service + _COMM=sshd + _COMM=sshd-session

That's the upstream journalmatch. Debian and Ubuntu name the unit ssh.service, but the _COMM=sshd and _COMM=sshd-session terms still match. fail2ban 1.1.1 also switches a jail to the journal automatically when no log file is found and the filter defines a journalmatch. Details: backends and the systemd journal and no log file found for jail.

fail2ban SSH on a custom port#

If sshd listens on a non-standard port, tell the jail. port = ssh means port 22, so fail2ban would block port 22 while the attacker keeps hammering 2222:

/etc/fail2ban/jail.local
[sshd]
enabled = true
port    = 2222

For multiple SSH ports (for example 22 internally and 2222 from outside), list them with commas:

ini
port = ssh,2222

The port only decides what the ban blocks; detection doesn't depend on it, because sshd logs the same messages on any port. To block banned addresses on every port instead, use the all-ports action for this jail: banaction = %(banaction_allports)s. Reload after any change:

bash
sudo fail2ban-client reload sshd

sshd filter modes: normal, ddos, extra, aggressive#

The sshd filter has four modes, selected with mode in the jail:

/etc/fail2ban/jail.local
[sshd]
enabled = true
mode    = aggressive
Mode Adds on top of the normal rules Example line it catches
normal (default) — wrong passwords, invalid users, AllowUsers/DenyUsers refusals Failed password for invalid user admin from 203.0.113.42
ddos Connections that never authenticate, protocol garbage, pre-auth disconnects Timeout before authentication for 203.0.113.42 port 51234
extra Clients with no usable auth method or no common algorithms Unable to negotiate with 203.0.113.42 port 51234: no matching host key type found.
aggressive ddos + extra combined all of the above

We ran these lines through fail2ban-regex with each mode: the Timeout before authentication line only matches in ddos/aggressive, and the Unable to negotiate line only in extra/aggressive.

Key-only SSH needs aggressive mode#

If you've set PasswordAuthentication no, bots can't produce Failed password lines any more. Their attempts end up as pre-auth disconnects instead:

log
Oct 25 18:36:10 web1 sshd-session[1234]: Connection closed by authenticating user root 203.0.113.42 port 51234 [preauth]
Oct 25 18:36:11 web1 sshd-session[1234]: Received disconnect from 203.0.113.42 port 51234:14: No supported authentication methods available [preauth]

Normal mode doesn't count either of these. The first matches in ddos and aggressive, the second in extra and aggressive. So on a key-only server, mode = aggressive is what makes fail2ban useful. Combined with key authentication it also keeps your logs readable and stops the endless scanning noise. The filter also has a publickey parameter: filter = sshd[mode=aggressive, publickey=invalid] additionally counts rejected keys offered for non-existent users.

sshd-session: OpenSSH 9.8 and newer#

Since OpenSSH 9.8 most authentication messages come from a process called sshd-session, not sshd. fail2ban's sshd filter only accepts that name from version 1.1.1 (_daemon = sshd(?:-session)?). Debian 13 patched its 1.1.0 package the same way. An older filter running against a newer OpenSSH misses every line — the jail looks healthy and bans nothing.

Check your filter:

bash
grep '^_daemon' /etc/fail2ban/filter.d/sshd.conf

If it prints _daemon = sshd, add an override instead of editing the stock file:

/etc/fail2ban/filter.d/sshd.local
[DEFAULT]
_daemon = sshd(?:-session)?

We confirmed with fail2ban-regex that a sshd-session[1234]: Failed password … line is missed with _daemon = sshd and matched once this override is in place. With the systemd backend, also make sure your journalmatch includes _COMM=sshd-session (the upstream 1.1.1 filter does).

Invalid users and preauth messages#

The default normal mode already counts the lines bots generate most:

log
Oct 25 18:36:10 web1 sshd[1234]: Invalid user admin from 203.0.113.42 port 51234
Oct 25 18:36:12 web1 sshd[1234]: Failed password for invalid user admin from 203.0.113.42 port 51234 ssh2

[preauth] lines (disconnects before authentication finished) are the grey zone: legitimate clients produce some too, which is why they only count in the stricter modes. If your maxretry is low and you use aggressive, add your own networks to ignoreip so a flaky office connection doesn't lock you out.

IPv6#

fail2ban has handled IPv6 since 0.10, and the sshd filter matches IPv6 addresses like any other. The nftables action keeps a separate set for IPv6 bans, so nothing extra is needed:

log
Oct 25 18:36:10 web1 sshd[1234]: Failed password for invalid user admin from 2001:db8::25 port 51234 ssh2

Keep ::1 in ignoreip next to 127.0.0.1/8. An allowipv6 warning at startup is harmless.

A complete jail.local for SSH#

/etc/fail2ban/jail.local
[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24
bantime  = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
bantime.maxtime   = 1w

[sshd]
enabled  = true
port     = 2222
mode     = aggressive
backend  = systemd
maxretry = 3

[recidive]
enabled = true

bantime.increment doubles the ban for each repeat offense (up to a week here). The recidive jail watches fail2ban's own log and bans addresses on all ports for a week (bantime = 1w, findtime = 1d) when they get banned again and again. The jail.local generator builds this file from a form.

Test the jail#

1. Test the filter against real log lines. Pass the mode you configured:

bash
# log file backend
sudo fail2ban-regex /var/log/auth.log 'sshd[mode=aggressive]'

# journal backend
sudo fail2ban-regex systemd-journal 'sshd[mode=aggressive]'

The summary at the end shows how many lines matched. If you have recent failed logins but 0 matched, you're reading the wrong source or have the sshd-session problem above. The fail2ban-regex guide explains the output.

2. Check the running jail:

output
$ sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed:	2
|  |- Total failed:	6
|  `- File list:	/var/log/auth.log
`- Actions
   |- Currently banned:	1
   |- Total banned:	1
   `- Banned IP list:	203.0.113.42

3. Trigger a real ban from a machine you can afford to lock out (a phone on mobile data works): try a few wrong passwords, or ssh nosuchuser@your-server repeatedly, then watch:

bash
sudo tail -f /var/log/fail2ban.log
log
2026-10-25 18:36:10,238 fail2ban.filter         [1997]: INFO    [sshd] Found 203.0.113.42 - 2026-10-25 18:36:10
2026-10-25 18:36:11,027 fail2ban.actions        [1997]: NOTICE  [sshd] Ban 203.0.113.42

Unban yourself afterwards with sudo fail2ban-client set sshd unbanip <IP> (more unban options).

Why fail2ban SSH is not working#

Symptom Likely cause Fix
Have not found any log file for sshd jail No /var/log/auth.log (Debian 12, minimal images) backend = systemd + python3-systemd
Total failed: 0 despite attacks Old filter vs OpenSSH 9.8+ sshd-session, or wrong backend sshd.local override above; check fail2ban-regex
Failures counted, nobody banned Key-only server in normal mode mode = aggressive
IP banned but still connecting Ban on port 22, sshd on 2222 Set port to the real port
Your own IP keeps getting banned Saved wrong key/password in a client, monitoring Add it to ignoreip

The general checklist is in fail2ban not banning.

sshd, dropbear and selinux-ssh filter reference#

The dropbear filter is for the small SSH server used on routers and embedded systems; the OpenWrt setup, including the log file it needs, is in fail2ban on OpenWrt.

The built-in filters this guide uses, exactly as fail2ban 1.1.1 ships them — with the log lines they match and a link to test them. Looking for something else? See xrdp, LDAP, DNS, VPN and the other network-service filters, or the full filter library.

sshd OpenSSH server

The sshd filter bans failed SSH logins: wrong passwords, invalid users, and in ddos/aggressive mode pre-auth disconnects and scans. File: /etc/fail2ban/filter.d/sshd.conf; jail [sshd]; log on Debian/Ubuntu: /var/log/auth.log; modes: aggressive, ddos, extra, normal.

Log lines it bans (from fail2ban's test suite):

  1. Jun 21 16:47:48 digital-mlhhyiqscv sshd[13709]: error: PAM: Authentication failure for myhlj1374 from 192.030.0.6
  2. May 29 20:56:52 imago sshd[28732]: error: PAM: Authentication failure for stefanor from example.com
  3. May 29 20:56:56 imago sshd[28732]: error: PAM: Authentication failure for test-ipv6 from 2606:2800:220:1:248:1893:25c8:1946
Show the failregex fail2ban compiles (22 lines)
prefregex
^<F-MLFID>(?:\[\])?\s*(?:<[^.]+\.[^.]+>\s+)?(?:\S+\s+)?(?:kernel:\s?\[ *\d+\.\d+\]:?\s+)?(?:@vserver_\S+\s+)?(?:(?:(?:\[\d+\])?:\s+[\[\(]?sshd(?:-session)?(?:\(\S+\))?[\]\)]?:?|[\[\(]?sshd(?:-session)?(?:\(\S+\))?[\]\)]?:?(?:\[\d+\])?:?)\s+)?(?:\[ID \d+ \S+\]\s+)?</F-MLFID>(?:(?:error|fatal): (?:PAM: )?)?<F-CONTENT>.+</F-CONTENT>$
failregex (22)
^[aA]uthentication (?:failure|error|failed) for <F-USER>.*?</F-USER> (?:from )?<HOST>( via \S+)?(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^User not known to the underlying authentication module for <F-USER>.*?</F-USER> (?:from )?<HOST>(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^Failed publickey for invalid user <F-USER>(?P<cond_user>\S+)|(?:(?! from ).)*?</F-USER> from <HOST>(?: (?:port \d+|on \S+)){0,2}(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
^Failed (?:<F-NOFAIL>publickey</F-NOFAIL>|\S+) for (?P<cond_inv>invalid user )?<F-USER>(?P<cond_user>\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+)</F-USER> from <HOST>(?: (?:port \d+|on \S+)){0,2}(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
^<F-USER>ROOT</F-USER> LOGIN REFUSED FROM <HOST>
^[iI](?:llegal|nvalid) user <F-USER>.*?</F-USER> (?:from )?<HOST>(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^User <F-USER>\S+|.*?</F-USER> (?:from )?<HOST> not allowed because not listed in AllowUsers(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^User <F-USER>\S+|.*?</F-USER> (?:from )?<HOST> not allowed because listed in DenyUsers(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^User <F-USER>\S+|.*?</F-USER> (?:from )?<HOST> not allowed because not in any group(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^refused connect from \S+ \(<HOST>\)
^Received <F-MLFFORGET>disconnect</F-MLFFORGET> from <HOST>(?: (?:port \d+|on \S+)){0,2}:\s*3: .*: Auth fail(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^User <F-USER>\S+|.*?</F-USER> (?:from )?<HOST> not allowed because a group is listed in DenyGroups(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^User <F-USER>\S+|.*?</F-USER> (?:from )?<HOST> not allowed because none of user's groups are listed in AllowGroups(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^<F-NOFAIL>pam_[a-z]+\(sshd:auth\):\s+authentication failure;</F-NOFAIL>(?:\s+(?:(?:logname|e?uid|tty)=\S*)){0,4}\s+ruser=<F-ALT_USER>\S*</F-ALT_USER>\s+rhost=<HOST>(?:\s+user=<F-USER>\S*</F-USER>)?(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^maximum authentication attempts exceeded for (?:invalid user )?<F-USER>.*?</F-USER> (?:from )?<HOST>(?: (?:port \d+|on \S+)){0,2}(?: ssh\d*)?(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^User <F-USER>\S+|.*?</F-USER> not allowed because account is locked(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*
^<F-MLFFORGET>Disconnecting</F-MLFFORGET>(?: from)?(?: (?:invalid|authenticating)) user <F-USER>\S+</F-USER> <HOST>(?: (?:port \d+|on \S+)){0,2}:\s*Change of username or service not allowed:\s*.*\[preauth\]\s*$
^Disconnecting: Too many authentication failures(?: for <F-USER>\S+|.*?</F-USER>)?(?: (?:port \d+|on \S+|\[preauth\])){0,3}\s*$
^<F-NOFAIL>Received <F-MLFFORGET>disconnect</F-MLFFORGET></F-NOFAIL> from <HOST>(?: (?:port \d+|on \S+)){0,2}:\s*11:
^<F-NOFAIL><F-MLFFORGET>(?:Connection (?:closed|reset)|Disconnect(?:ed|ing))</F-MLFFORGET></F-NOFAIL>(?: (?:by|from))?(?: (?:invalid|authenticating) user <F-USER>\S+|.*?</F-USER>)?(?: from)? <ADDR>(?: (?:port \d+|on \S+)){0,2}(?:: (?!Too many authentication failures)[^\[]+)?(?: \[preauth\])?\s*$
^<F-MLFFORGET><F-MLFGAINED>Accepted \w+</F-MLFGAINED></F-MLFFORGET> for <F-USER>\S+</F-USER> from <HOST>(?:\s|$)
^<F-NOFAIL>Connection from</F-NOFAIL> <HOST>

Test sshd in the browser or on the server: sudo fail2ban-regex /var/log/auth.log sshd · source

dropbear Dropbear SSH

The dropbear filter bans failed password logins and bad users on the Dropbear SSH server. File: /etc/fail2ban/filter.d/dropbear.conf; jail [dropbear]; log on Debian/Ubuntu: /var/log/auth.log.

Log lines it bans (from fail2ban's test suite):

  1. Mar 24 15:25:51 buffalo1 dropbear[4092]: bad password attempt for 'root' from 198.51.100.87:5543
  2. Feb 11 15:23:17 dropbear[1252]: login attempt for nonexistent user from ::ffff:198.51.100.215:60495
  3. Mar 24 15:25:51 buffalo1 dropbear[4092]: bad password attempt for 'root' from 198.51.100.87:5543
Show the failregex fail2ban compiles (4 lines)
prefregex
^(?:\[\])?\s*(?:<[^.]+\.[^.]+>\s+)?(?:\S+\s+)?(?:kernel:\s?\[ *\d+\.\d+\]:?\s+)?(?:@vserver_\S+\s+)?(?:(?:(?:\[\d+\])?:\s+[\[\(]?dropbear(?:\(\S+\))?[\]\)]?:?|[\[\(]?dropbear(?:\(\S+\))?[\]\)]?:?(?:\[\d+\])?:?)\s+)?(?:\[ID \d+ \S+\]\s+)?(?:\[\d+\] \w{2,3} [\d:\s]+)?<F-CONTENT>(?:[Ll]ogin|[Bb]ad|[Ee]xit).+</F-CONTENT>$
failregex (4)
^[Ll]ogin attempt for nonexistent user (?:'<F-USER>.*</F-USER>' )?from <HOST>:\d+$
^[Bb]ad (?:PAM )?password attempt for '<F-USER>.+</F-USER>' from <HOST>(?::\d+)?$
^[Ee]xit before auth from \<?<ADDR>:\d+\>?: (?:\([^\)]*\): )?Max auth tries reached - user '<F-USER>.+</F-USER>'\s*$
^[Ee]xit before auth \(user '.+', \d+ fails\): Max auth tries reached - user '<F-USER>.+</F-USER>' from <HOST>:\d+\s*$

Test dropbear in the browser or on the server: sudo fail2ban-regex /var/log/auth.log dropbear · source

selinux-ssh OpenSSH via SELinux audit

The selinux-ssh filter bans failed SSH logins recorded in the SELinux audit log. File: /etc/fail2ban/filter.d/selinux-ssh.conf; jail [selinux-ssh]; log on Debian/Ubuntu: /var/log/audit/audit.log.

Log lines it bans (from fail2ban's test suite):

  1. type=USER_ERR msg=audit(1373330717.000:4070): user pid=12000 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=PAM:bad_ident acct="?" exe="/usr/sbin/sshd" hostname=173.242.116.187 addr=173.242.116.187 terminal=ssh res=failed'
  2. type=USER_ERR msg=audit(1372557500.000:61747): user pid=23684 uid=0 auid=0 ses=76 subj=unconfined_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=PAM:bad_ident acct="?" exe="/usr/sbin/sshd" hostname=113.240.248.18 addr=113.240.248.18 terminal=ssh res=failed'
  3. type=USER_AUTH msg=audit(1373129280.000:9): user pid=1277 uid=0 auid=4294967295 ses=4294967295 subj=system_u:system_r:sshd_t:s0-s0:c0.c1023 msg='op=pubkey acct="root" exe="/usr/sbin/sshd" hostname=? addr=194.228.20.113 terminal=ssh res=failed'
Show the failregex fail2ban compiles (1 line)
failregex (1)
^type=USER_(ERR|AUTH) msg=audit\(:\d+\): (?:user )?pid=\d+ uid=0 auid=\d+ ses=\d+ subj=(?:unconfined_u|system_u):system_r:sshd_t:s0-s0:c0\.c1023 msg='(?:(?!acct=|exe=|addr=|terminal=|res=)\w+=(?:"[^"]+"|\S*) )*acct=(?:"<F-USER>[^"]+</F-USER>"|<F-ALT_USER>\S+</F-ALT_USER>) exe="/usr/sbin/sshd" (?:(?!acct=|exe=|addr=|terminal=|res=)\w+=(?:"[^"]+"|\S*) )*addr=<ADDR> terminal=ssh res=failed'(?:\x1D|$)

Test selinux-ssh in the browser or on the server: sudo fail2ban-regex /var/log/audit/audit.log selinux-ssh · source

FAQ#

How do I enable fail2ban for SSH?#

Create /etc/fail2ban/jail.local with an [sshd] section containing enabled = true, plus backend = systemd if your system has no /var/log/auth.log. Run sudo fail2ban-client reload and confirm with sudo fail2ban-client status sshd. Debian and Ubuntu packages already enable the sshd jail by default.

How do I use fail2ban with a custom SSH port?#

Set port in the sshd jail to the port sshd listens on, for example port = 2222, or port = ssh,2222 for several ports. Detection works on any port, but the ban only blocks the ports listed, so leaving port = ssh lets attackers continue on 2222.

What does mode = aggressive do in the sshd jail?#

It combines the ddos and extra modes with the normal rules. Besides wrong passwords and invalid users it counts pre-auth disconnects, connections that never authenticate, and clients without a usable auth method or algorithm. Use it on servers that only accept SSH keys, where bots never produce "Failed password" lines.

Do I need fail2ban if I only use SSH key authentication?#

Keys stop password guessing, so fail2ban is no longer what keeps you safe. It still cuts log noise, saves resources and slows scanners down, and with mode = aggressive it bans clients that keep connecting without valid keys. Many admins run both: keys for security, fail2ban for hygiene.

Should I use sshd or ssh as the jail name?#

The jail is called sshd in fail2ban 0.10 and later, so use [sshd] in jail.local and fail2ban-client status sshd. Old tutorials use [ssh]; that section doesn't exist in current jail.conf, so settings placed under it have no effect.

Spotted something wrong or out of date? Tell us — every page is checked against the current fail2ban release.