Stop brute-force attacks on SSH and web logins
Any server with SSH open on the internet gets password-guessing attempts (a brute-force attack). That is normal background noise. It is a problem only if a weak password lets someone in, or if the volume slows the server down. First check that no guess succeeded. Then switch SSH to key-only logins, which makes the guessing useless, and use fail2ban and rate limits to cut the noise.
Do this first.
- Check for successful logins you don't recognise (command below). If you find one, stop here and go to Leaked credentials.
- Make sure your own SSH key login works, then turn off password logins for SSH.
- Install fail2ban with the
sshdjail. Add your own fixed IP address toignoreipfirst.
Symptoms
- Many log lines such as
Failed password for root from 198.51.100.7orInvalid user admin from .... - High CPU from
sshdor PHP processes. Hundreds of POST requests to/wp-login.php,/xmlrpc.phpor your panel's login URL. - Locked-out user accounts in your web application.
Read the logs
Debian 12 and later don't install rsyslog by default, so there may be no /var/log/auth.log. The systemd journal works everywhere. On RHEL-family systems, use -u sshd (the text file is /var/log/secure).
# Did anyone get in? (look for addresses you don't know)
sudo journalctl -u ssh --since "7 days ago" -g 'Accepted (password|publickey|keyboard-interactive)'
# Top 20 guessing addresses in the last hour
sudo journalctl -u ssh --since "1 hour ago" -g 'Failed password|Invalid user' --no-pager \
| grep -oE 'from [0-9a-fA-F.:]+' | awk '{print $2}' | sort | uniq -c | sort -rn | head -20
# Top 20 addresses posting to a web login page
sudo awk '$6 == "\"POST" && $7 ~ /wp-login|login/ {print $1}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -20
The last command assumes nginx's default combined log format.
1. Make SSH key-only
Put your settings in a drop-in file with a low number. sshd uses the first value it reads for each keyword, and Debian, Ubuntu and RHEL 9 and later read /etc/ssh/sshd_config.d/*.conf before the rest of the main file. A file such as 50-cloud-init.conf on cloud images may otherwise switch passwords back on.
sudo tee /etc/ssh/sshd_config.d/10-no-passwords.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
LoginGraceTime 30
EOF
sudo sshd -t && sudo systemctl reload ssh # RHEL family: reload sshd
Keep your current session open and test a new login from a second terminal. Use PermitRootLogin no if you always log in as a normal user. Recent OpenSSH releases also slow down repeat offenders on their own: PerSourcePenalties is on by default (for example in OpenSSH 10.0 on Debian 13).
2. Set up fail2ban
Debian/Ubuntu: sudo apt install fail2ban. AlmaLinux/Rocky: enable EPEL (sudo dnf install epel-release), then sudo dnf install fail2ban. On RHEL itself, enable EPEL as described in Fedora's EPEL documentation. Never edit jail.conf. Create /etc/fail2ban/jail.local:
[DEFAULT]
# your own fixed IPs/VPN range, so you can't ban yourself
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
bantime = 1h
findtime = 10m
maxretry = 5
bantime.increment = true
# match your firewall: nftables-multiport, firewallcmd-rich-rules, ufw
banaction = nftables-multiport
[sshd]
enabled = true
backend = systemd
[nginx-http-auth]
enabled = true
[nginx-limit-req]
enabled = true
[nginx-botsearch]
enabled = true
sudo systemctl enable --now fail2ban
sudo fail2ban-client reload
sudo fail2ban-client status
sudo fail2ban-client status sshd
The defaults are bantime = 10m, findtime = 10m and maxretry = 5. bantime.increment makes the ban longer each time an address comes back. backend = systemd reads the journal, which is needed when there is no auth.log. Only enable the nginx jails if nginx is installed. If an enabled jail's log file is missing, fail2ban can fail to start. nginx-limit-req works together with the nginx rate limit in the next step.
3. Rate-limit web logins
In nginx, define a zone in the http block, then apply it inside the location that already serves your login URL:
# http { ... }
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
# inside the existing location for /wp-login.php (or your login path)
limit_req zone=login burst=5 nodelay;
limit_req_status 429;
This allows each IP address about 10 login requests a minute, with a burst of 5. Rejected requests get HTTP 429 and an error-log line, which the nginx-limit-req jail turns into a ban. Test and reload (this reloads a service):
sudo nginx -t && sudo systemctl reload nginx
Ubuntu's ufw has a simple SSH rate limit too. sudo ufw limit 22/tcp denies an address that opens 6 or more connections within 30 seconds.
4. Add a second factor
- SSH: keys already act as "something you have". For more, use hardware-backed keys (
ssh-keygen -t ed25519-sk), or require a key plus a one-time code withAuthenticationMethods publickey,keyboard-interactiveand a TOTP PAM module (Debian/Ubuntu packagelibpam-google-authenticator). This needsKbdInteractiveAuthentication yesand a PAM change. Test it in a second session. - Web apps and panels: turn on the built-in two-factor login of your CMS, control panel, registrar and cloud account.
5. Move admin panels off the internet
Database tools, control panels, monitoring dashboards and CMS admin areas don't need to be public. Make them listen on 127.0.0.1 or a VPN address only, or allow only your VPN range in the firewall:
sudo ufw allow from 10.8.0.0/24 to any port 8443 proto tcp
# or reach a localhost-only panel through SSH:
ssh -L 8443:127.0.0.1:8443 alice@server # then open https://localhost:8443
A small WireGuard or OpenVPN server, or your provider's private network, are common choices. You can also restrict SSH itself to the VPN range once the VPN is reliable.
Confirm it is working
sudo sshd -T | grep -i passwordauthenticationprintspasswordauthentication no.ssh -o PubkeyAuthentication=no alice@serverfails withPermission denied (publickey).sudo fail2ban-client status sshdshows banned addresses, and the failure count in the journal drops.- Repeated POSTs to the login page get HTTP 429 in the access log.
When to call a professional
If a guess succeeded, treat it as a break-in: see Signs of compromise and Incident response. If the login flood is large enough to slow the whole server, it is a traffic flood problem. See DDoS and floods and contact your host.
Sources
- sshd_config(5) manual page, OpenBSD/OpenSSH
- ssh-keygen(1) manual page, OpenBSD/OpenSSH
- jail.conf(5), Debian manpages
- jail.conf (upstream defaults), fail2ban project
- fail2ban-client(1), Debian manpages
- fail2ban package (trixie), Debian
- Module ngx_http_limit_req_module, nginx documentation
- ufw(8), Ubuntu manpages
- Debian 12 release notes: Changes to system logging
- journalctl(1), systemd manual
- libpam-google-authenticator package, Debian