DevOps & Workflow9 min read

    5 Ways to Stop SSH Brute-Force Attacks on Linux

    By Amir— Reliability & Network Engineering
    Share

    Put a Linux server on a public IP and look at its auth log a day later:

    sudo journalctl -u ssh --since yesterday | grep -c 'Failed password'
    # 14823
    

    Fifteen thousand guesses in 24 hours is a normal, quiet day. Botnets scan the whole IPv4 space continuously for port 22 and try credential lists against anything that answers. None of it is targeted, all of it is automated, and it only has to work once.

    The good news is that SSH brute-forcing is a solved problem. The five controls below, applied in order, take password guessing off the table entirely and then shrink the attack surface for everything else. Each section gives the configuration for Debian/Ubuntu and for RHEL, AlmaLinux and Rocky Linux, plus the command that proves it worked.

    Before you start: keep your current SSH session open while you make changes, and test every change from a second terminal before closing the first. Every lock-out in this article's history came from skipping that step.

    1. Disable passwords; use keys only

    A brute-force attack guesses passwords. If sshd does not accept passwords, there is nothing to guess. This single change removes the entire class of attack; the remaining four controls deal with the noise and with other weaknesses.

    Generate a key and install it

    On your workstation:

    ssh-keygen -t ed25519 -a 64 -C "you@workstation"
    ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server
    

    Ed25519 keys are short, fast and the current default recommendation; -a 64 strengthens the passphrase protection on the private key. Prefer a passphrase and an SSH agent over an unprotected key file.

    Confirm you can log in with the key (ssh user@server should not ask for the account password), then — and only then — turn passwords off.

    Turn off password authentication

    On Debian 12 / Ubuntu 22.04+ and RHEL 9 / AlmaLinux 9 / Rocky 9, put overrides in a drop-in file rather than editing sshd_config directly:

    sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'CONF'
    PasswordAuthentication no
    KbdInteractiveAuthentication no
    PubkeyAuthentication yes
    PermitRootLogin prohibit-password
    CONF
    sudo sshd -t && sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd
    

    Two gotchas:

    • Drop-ins are read in lexical order and the first value wins in OpenSSH, so a PasswordAuthentication yes in 50-cloud-init.conf (Ubuntu cloud images ship one) would override your 10-… only if its number sorted lower. Check with sudo sshd -T | grep -i passwordauth — -T prints the effective configuration.
    • The service is ssh on Debian/Ubuntu and sshd on RHEL-family; the command above tries both.

    Verify

    From another machine, force a password attempt:

    ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password user@server
    # user@server: Permission denied (publickey).
    

    That (publickey) — with no password in the list — is what you want to see.

    2. Ban repeat offenders with fail2ban

    With passwords off, the bots still connect, fail and retry, which costs CPU and fills logs. fail2ban watches the auth log and adds firewall rules to drop an IP after N failures.

    # Debian / Ubuntu
    sudo apt install -y fail2ban
    
    # RHEL, AlmaLinux, Rocky (EPEL)
    sudo dnf install -y epel-release
    sudo dnf install -y fail2ban fail2ban-firewalld
    

    Never edit jail.conf; create jail.local:

    sudo tee /etc/fail2ban/jail.local >/dev/null <<'CONF'
    [DEFAULT]
    bantime  = 1h
    findtime = 10m
    maxretry = 5
    bantime.increment = true
    bantime.maxtime   = 1w
    ignoreip = 127.0.0.1/8 ::1 203.0.113.0/24   # your office / VPN range
    
    [sshd]
    enabled = true
    mode    = aggressive
    CONF
    sudo systemctl enable --now fail2ban
    
    • bantime.increment doubles the ban for IPs that come back, up to a week.
    • mode = aggressive also matches pre-auth disconnects and invalid-user probes, not just failed passwords.
    • On RHEL 9 and Debian 12, logs are in the journal; the shipped sshd jail uses backend = systemd automatically. If fail2ban reports Have not found any log file for sshd jail, add backend = systemd under [sshd].

    Verify

    sudo fail2ban-client status sshd
    # Status for the jail: sshd
    # |- Filter
    # |  |- Currently failed: 3
    # |  `- Total failed:     412
    # `- Actions
    #    |- Currently banned: 27
    #    `- Banned IP list:   45.0.0.12 ...
    

    Unban yourself if needed: sudo fail2ban-client set sshd unbanip 198.51.100.7.

    3. Tighten sshd's own limits

    These settings reduce what each connection can do and how long it can hang around. Add them to the same drop-in file:

    MaxAuthTries 3
    LoginGraceTime 20
    MaxStartups 10:30:60
    AllowGroups ssh-users
    X11Forwarding no
    
    • MaxAuthTries 3 — three authentication attempts per connection instead of six.
    • LoginGraceTime 20 — a connection that has not authenticated in 20 s is dropped; this is what stops slowloris-style connection exhaustion.
    • MaxStartups 10:30:60 — after 10 unauthenticated connections, randomly drop 30 % of new ones, rising to 100 % at 60. Protects the daemon under a flood.
    • AllowGroups ssh-users — only members of that group may log in at all; service accounts and the postgres user stop being valid targets. Create it and add yourself before reloading: sudo groupadd ssh-users && sudo usermod -aG ssh-users "$USER".

    Reload with sudo sshd -t && sudo systemctl reload ssh (or sshd) and confirm with sudo sshd -T | grep -Ei 'maxauthtries|logingracetime|allowgroups'.

    TCP Wrappers (/etc/hosts.allow, /etc/hosts.deny), which older guides recommend here, were removed from RHEL 8+ and OpenSSH no longer links against libwrap on most distributions. Use the firewall instead — next section.

    4. Restrict and rate-limit at the firewall

    If you know where legitimate logins come from — an office range, a VPN, a bastion — allow only those. If you don't, rate-limit new connections so a single source cannot hammer the port.

    Allow-list (best)

    # Ubuntu / Debian with ufw
    sudo ufw allow from 203.0.113.0/24 to any port 22 proto tcp
    sudo ufw deny 22/tcp
    
    # RHEL-family with firewalld
    sudo firewall-cmd --permanent --remove-service=ssh
    sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.0/24" service name="ssh" accept'
    sudo firewall-cmd --reload
    

    Rate limit (when you cannot allow-list)

    # ufw: max 6 new connections per 30 s per source
    sudo ufw limit 22/tcp
    
    # firewalld
    sudo firewall-cmd --permanent --add-rich-rule='rule service name="ssh" limit value="6/m" accept'
    sudo firewall-cmd --reload
    
    # raw nftables
    sudo nft add rule inet filter input tcp dport 22 ct state new limit rate over 6/minute drop
    

    Verify from outside with a quick loop of ssh -o ConnectTimeout=3 user@server exit — the seventh attempt within the window should time out rather than get a banner.

    5. Reduce exposure: bastion, VPN, 2FA, and (optionally) the port

    Everything above hardens an open port. The strongest position is not having port 22 reachable from the internet at all:

    • Bastion host — one small, heavily monitored jump server is reachable; everything else accepts SSH only from the bastion's IP. Configure clients with ProxyJump bastion in ~/.ssh/config so the workflow is unchanged.
    • VPN or zero-trust tunnel — WireGuard, Tailscale or your cloud provider's private connectivity; SSH listens only on the private address. (See how to monitor WireGuard once it is in place.)
    • Second factor — with libpam-google-authenticator (Debian/Ubuntu) or google-authenticator (EPEL) and AuthenticationMethods publickey,keyboard-interactive, a stolen key alone is not enough. SSH certificates (ssh-keygen -s ca_key …) achieve similar control for teams.
    • Changing the port (Port 2222) stops the dumbest scanners and makes logs quieter, but it is obscurity, not security — a real scan finds it in seconds. If you do it on RHEL-family, tell SELinux first: sudo semanage port -a -t ssh_port_t -p tcp 2222, and open it in firewalld.

    Watch it, don't just set it

    Hardening without monitoring tells you nothing when something changes. Three things worth keeping an eye on:

    # Failed logins in the last hour, by source
    sudo journalctl _COMM=sshd --since -1h | grep -oE 'from [0-9a-f.:]+' | sort | uniq -c | sort -rn | head
    
    # Successful logins (anything here you don't recognise is the real alarm)
    sudo journalctl _COMM=sshd --since today | grep 'Accepted'
    
    # Who is banned right now
    sudo fail2ban-client banned
    

    For an always-on view, point a Linux server monitor at the host and alert on CPU or connection-count spikes — a brute-force wave that gets past fail2ban shows up there before anyone reads a log.

    Troubleshooting

    • Locked out after disabling passwords — use the provider's console/VNC, log in locally, and set PasswordAuthentication yes temporarily. This is why you test from a second terminal first.
    • Permission denied (publickey) with the right key — on the server, ~/.ssh must be 700 and authorized_keys 600, owned by the user, and the home directory must not be group-writable. sudo journalctl -u ssh -n 50 names the exact complaint (Authentication refused: bad ownership or modes). On RHEL with a non-standard home directory, also check SELinux context: restorecon -Rv ~/.ssh.
    • fail2ban banned my own IP — sudo fail2ban-client set sshd unbanip <ip> from the console, then add the IP to ignoreip.
    • fail2ban shows 0 failures while the log fills up — wrong backend or log path; set backend = systemd under [sshd] on journal-based systems and restart fail2ban.
    • Drop-in settings ignored — a lower-numbered file sets the same key first (sshd -T shows the winner), or the main sshd_config lacks the Include /etc/ssh/sshd_config.d/*.conf line at the top (older images).
    • New port refuses connections on RHEL — SELinux (semanage port -a …) or firewalld (--add-port=2222/tcp), usually both.

    Summary

    1. Keys only — PasswordAuthentication no ends brute-forcing outright. Do this first, test from a second session.
    2. fail2ban — bans repeat offenders; bantime.increment deals with the persistent ones.
    3. sshd limits — MaxAuthTries 3, LoginGraceTime 20, MaxStartups, AllowGroups.
    4. Firewall — allow-list known sources, or rate-limit new connections.
    5. Exposure — bastion, VPN, 2FA; a port change is cosmetic.

    Then watch the auth log and the host, because the configuration you verified today is not necessarily the one running next year.