SERVER SECURITY GUIDE

How to Stop SSH Brute-Force Attacks on a VPS

Short answer: the one change that really ends SSH brute-force attacks is turning passwords off, so every guess has nothing to hit. Do it only after you have proved that key login works. Then add cheaper layers on top: PermitRootLogin no, an AllowUsers list, sshd's own limits, and fail2ban or a ufw rate limit. Measure first, so you can see each layer work: count failed logins with journalctl, see who sends them, change one thing, count again. Most of the noise keeps arriving afterwards and is harmless. The rollout order below never locks you out of your own server.

Quick win (15 minutes)

  • Count your baseline: sudo journalctl -u ssh --since yesterday --no-pager -o cat -g 'Failed password' | wc -l (the unit is sshd on the RHEL family).
  • Open a second SSH session and leave it connected, and find the web console or rescue mode in your provider's panel.
  • Prove key login works from a new terminal: ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no myserver.
  • Set PasswordAuthentication no, KbdInteractiveAuthentication no and PermitRootLogin no, run sudo sshd -t, reload, then test from a third terminal.
  • Run the baseline count again tomorrow and compare: the attempts keep coming, but none can succeed.

What actually stops SSH brute force, in order

Turn passwords off. A brute-force attack works by guessing credentials, so when the server accepts only key-based authentication there is nothing to guess, and thousands of tries can never succeed. Everything else in this guide either reduces noise or adds depth. Apply the layers in this order, and measure before and after each one so you know it did something:

  1. Passwords off. PasswordAuthentication no, after you have confirmed that key login works. This removes the target.
  2. Root login off. PermitRootLogin no takes the most guessed account name out of play.
  3. An allow list. AllowUsers means only named accounts can log in at all.
  4. sshd's own limits. MaxAuthTries, LoginGraceTime and MaxStartups slow each connection down.
  5. Rate limiting. fail2ban or ufw limit cuts the volume of attempts reaching sshd.
  6. A different port. Cosmetic: quieter logs, no real protection.
  7. Two-factor authentication or a bastion host. For servers where a stolen key would be costly.

This guide is the focused, measure-then-fix companion to the broader SSH hardening best practices guide, which covers ciphers, forwarding and certificates. Here we only deal with password guessing, and with proving that each defence works. For the wider picture, see the VPS security checklist.

Measure the attack first

Count what you are dealing with before changing anything. A baseline lets you see the effect of every layer instead of guessing. On a systemd server, sshd logs to the journal, read with journalctl. The unit is ssh on Debian and Ubuntu and sshd on the RHEL family. Filter by unit rather than by process name: since OpenSSH 9.8 the server is split into a listener and a per-session binary, so some messages come from a process called sshd-session (see the OpenSSH 9.8 release notes).

# failed password attempts since yesterday
sudo journalctl -u ssh --since yesterday --no-pager -o cat -g 'Failed password' | wc -l

# the ten busiest source addresses
sudo journalctl -u ssh --since yesterday --no-pager -o cat -g 'Failed password' \
  | grep -oE 'from [0-9a-fA-F.:]+ port' | awk '{print $2}' \
  | sort | uniq -c | sort -rn | head

# the ten most tried account names
sudo journalctl -u ssh --since yesterday --no-pager -o cat -g 'Failed password' \
  | sed -E 's/.*Failed password for (invalid user )?([^ ]+) from.*/\2/' \
  | sort | uniq -c | sort -rn | head

The journalctl manual documents -u (unit), --since (it understands yesterday), -g (filter by regular expression) and -o cat (message text only). If your server already has passwords off, search for 'Invalid user' instead, because there will be no password failures. If sshd writes to a file such as /var/log/auth.log (Debian, Ubuntu) or /var/log/secure (RHEL family), run the same grep on that file. Two more read-only commands show failed and successful logins:

sudo lastb -i -n 20
last -i -n 20

Per the lastb manual, it reads /var/log/btmp and that file may not exist on your system, so treat journalctl as the dependable source. A brute-force run looks like this (addresses are from documentation ranges):

Failed password for invalid user admin from 203.0.113.50 port 41022 ssh2
Failed password for root from 203.0.113.50 port 41030 ssh2
Invalid user test from 198.51.100.7 port 52114
maximum authentication attempts exceeded for root from 203.0.113.50 port 41044 ssh2

Read them like this. Invalid user means the account does not exist on your server: the bot is working through a list of names, not targeting you. Many names from one address is a dictionary run. One name from many addresses is a distributed run, and it is the pattern a per-address counter handles worst. The maximum authentication attempts exceeded line is MaxAuthTries doing its job. None of this is a breach. The line that matters is a success right after a burst of failures from the same address or account, so also check sudo journalctl -u ssh --since yesterday --no-pager -o cat -g 'Accepted' and compare it with your own logins. If you find one you cannot explain, go to the server breach incident response guide.

Write your baseline down: total failures, top addresses, top names. It is the number you will beat.

Layer 1: remove passwords safely

This is the change that ends the attack, and it is also the one that can lock you out, so use a fixed routine every time you touch sshd:

  1. Keep your current session open until a brand-new one has succeeded.
  2. Know how to reach your provider's web console or rescue mode, which works even when SSH does not.
  3. Back up the file: sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.
  4. Validate before every reload with sudo sshd -t, and test from a second terminal.

First prove that key login works. A Host alias in ~/.ssh/config on your workstation keeps the commands short:

Host myserver
    HostName 203.0.113.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes
ssh -o PreferredAuthentications=publickey -o PasswordAuthentication=no myserver

If that gives you a shell without asking for a password, the key works. If it fails, stop and fix it first, starting with the Permission denied (publickey) guide. A key pair is made with ssh-keygen. Then edit sshd_config:

PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no

Both lines matter. The sshd_config(5) manual gives yes as the default for PasswordAuthentication and for KbdInteractiveAuthentication, and the portable manual notes that with UsePAM the keyboard-interactive method usually serves an equivalent role to password login (see PAM). Disabling only one can leave a password prompt. Now validate, read back what sshd will really use, and reload:

sudo sshd -t
sudo sshd -T | grep -iE '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication) '
sudo systemctl reload ssh     # the unit is sshd on the RHEL family

Per the sshd(8) manual, -t checks the configuration file and -T prints the effective configuration. Then, from a new terminal, prove both halves: that your key still works, and that a password is refused.

ssh myserver
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password myserver

The second command must end in Permission denied without ever offering a password prompt. If it still prompts, something overrides your setting. The manual says that for each keyword the first obtained value is used, and that Include files are processed in lexical order, so an earlier line or a drop-in file shipped by your hosting image can win. Find it:

sudo grep -rsiE '^[[:space:]]*(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/

If you do get locked out anyway, the SSH lockout recovery guide walks through the way back in.

Layers 2 and 3: root login and AllowUsers

With passwords off, root can still log in with a key if one sits in root's authorized_keys. The manual lists the accepted values of PermitRootLogin as yes, prohibit-password, forced-commands-only and no, and gives prohibit-password as the default. That default blocks passwords for root but not keys, so set no and use sudo from a named account. Before you do, confirm that a normal account with working sudo exists and that you can log in as it.

Then add an allow list. With AllowUsers set, the manual says login is allowed only for names that match one of the patterns, and by default login is allowed for all users. List every account that must connect, including backup and deployment accounts, or their jobs will start failing:

PermitRootLogin no
AllowUsers deploy admin
sudo sshd -t
sudo sshd -T | grep -iE '^(permitrootlogin|allowusers) '
sudo systemctl reload ssh
ssh -l root myserver

The last command, run from a second terminal, must be refused. A refusal by PermitRootLogin is logged as ROOT LOGIN REFUSED FROM, and an unlisted account produces a line saying the user is not allowed because it is not listed in AllowUsers. Search for both with journalctl -u ssh -g 'not allowed|REFUSED'. A user name can also be combined with a source address pattern in AllowUsers; see sshd_config(5) for the exact form. If you set both AllowUsers and AllowGroups, a login must satisfy both lists.

These two layers have a clear payoff in the logs: the bots keep guessing root and admin, and now those names are refused before authentication matters.

Want proof your SSH hardening actually works?

A free, read-only server check reviews your SSH settings, firewall and exposed services, and flags what still lets guesses through.

Get your free server check Talk to a security engineer

Layer 4: sshd limits and built-in penalties

OpenSSH already throttles unauthenticated clients. The documented defaults, from the sshd_config(5) manual, are:

  • MaxAuthTries is 6: authentication attempts allowed per connection.
  • LoginGraceTime is 120 seconds: how long a client may stay unauthenticated before the server disconnects it.
  • MaxStartups is 10:30:100: once 10 unauthenticated connections are open, new ones are refused with 30% probability, rising linearly until all are refused at 100.
  • PerSourcePenalties is on by default: since OpenSSH 9.8 (released 2024-07-01), addresses that fail authentication, hold connections without logging in or crash the daemon are temporarily refused, together with the rest of their network range. The current manual lists defaults such as 5 seconds for an authentication failure, growing up to a maximum of 10 minutes.

Print what your server really uses. Older packages lack the newest option, so check your version too:

ssh -V
sudo sshd -T | grep -Ei 'maxauthtries|logingracetime|maxstartups|persourcepenalties'

A modest tightening is reasonable, as long as you remember what these limits are. They work per connection or per source, they slow an attacker, and a bot that reconnects gets a fresh counter. They do not remove the target, which is why they come after passwords:

MaxAuthTries 3
LoginGraceTime 30
MaxStartups 10:30:60

Two cautions. A client whose agent holds many keys can use up MaxAuthTries before it reaches the right one and see Too many authentication failures. Fix it on the client with IdentitiesOnly yes, as in the Host block above. And a low LoginGraceTime can be too short if you add a typed second factor later. Also consider PerSourcePenaltyExemptList with your office or VPN address, so the penalties never apply to you. Validate with sudo sshd -t and reload as before.

Layer 5: fail2ban or a ufw rate limit

Rate limiting cuts the volume of attempts that reach sshd. Once passwords are off it adds depth and quieter logs rather than closing a hole. Pick one to start, measure, and add the other only if you need it.

Option A: fail2ban

fail2ban reads log lines, counts failures per address and bans offenders at the firewall. The shipped jail.conf defaults to bantime = 10m, findtime = 10m and maxretry = 5, and tells you to put changes in jail.local. Put your own address in ignoreip before enabling anything:

[DEFAULT]
ignoreip = 127.0.0.1/8 ::1 203.0.113.42

[sshd]
enabled  = true
maxretry = 4
findtime = 10m
bantime  = 1h
bantime.increment = true
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd

The status output shows the failure and ban counters, which is your proof that it works. Its weakness is the one named earlier: it counts per address, so an attacker who rotates addresses can stay under the threshold. If the jail runs but nothing is banned, read why fail2ban is not banning. Full tuning is in the fail2ban setup guide.

Option B: ufw limit

The ufw manual documents a limit rule: it normally allows the connection, but denies it if an address attempts 6 or more connections within 30 seconds. Its typical usage is ufw limit ssh/tcp. That is rate limiting with no extra daemon, and the same machinery covered in the Linux firewall guide and the ufw entry. There is one trap: the manual says rule ordering matters and the first match wins, so an existing plain allow rule for SSH makes the limit rule dead. Add the limit first, then remove the allow, and you never drop access:

sudo ufw limit ssh/tcp
sudo ufw status numbered
sudo ufw delete allow 22/tcp     # or: sudo ufw delete allow OpenSSH
sudo ufw status verbose

Prefixing the original rule with delete removes both the IPv4 and the IPv6 version. If ufw is not enabled yet, add the SSH rule before sudo ufw enable, because the manual warns that enabling can drop existing connections such as SSH. Remember that limit counts connection attempts, not failures: a script that opens many SSH connections in a few seconds can trip it.

The honest value of changing the port

Moving sshd off port 22 does cut the stream of drive-by attempts, because most bots only try the default port. You can measure it: count failures before and after. What it does not do is stop anyone who looks. A port scan finds the new port in seconds, and a targeted attacker is not slowed at all. It is a noise filter, not a security control, which is why it sits so low in the order. Meanwhile it has real costs: every client, firewall rule, monitoring check and script must learn the new number.

If you still want it, use the fact that the manual permits multiple Port lines, so you can run both ports while you test and never cut yourself off:

# /etc/ssh/sshd_config
Port 22
Port 2222
  1. Open the new port first, in ufw (sudo ufw allow 2222/tcp) and in any provider firewall or security group.
  2. Run sudo sshd -t, reload, and confirm what is listening: sudo ss -tlnp | grep sshd.
  3. From a second terminal, test it: ssh -p 2222 myserver.
  4. Only after that works, remove Port 22 and its firewall rule, validate and reload again.

If the new port does not appear in ss, your system may start sshd through a systemd socket unit that owns the listening port. Check your distribution's documentation for how to change it, and do not close your working session until the new port answers. Update the limit or fail2ban port as well, for example sudo ufw limit 2222/tcp.

Higher risk: two-factor authentication or a bastion host

Everything above defends against guessing. A different risk is a stolen key: a laptop backup or a compromised workstation hands an attacker a valid login with no failed attempts to see. For servers that hold customer data, or where several people have admin access, add a second factor or reduce who can reach SSH at all.

Two-factor authentication. The AuthenticationMethods directive lets you require several methods in sequence, for example a key plus a second factor. Two practical options are a hardware-backed FIDO2 key, and a TOTP code through PAM. Neither is needed to beat brute force, but both make a stolen key insufficient. A wrong second-factor attempt counts against MaxAuthTries, so do not set that value too low. The rollout has its own lockout risks, covered step by step in the SSH two-factor authentication guide.

A bastion host. A bastion host is the one deliberately exposed machine. Internal servers accept SSH only from it, so they have no public SSH port to attack. OpenSSH supports it directly: the ssh manual describes -J and the ProxyJump option as connecting to the jump host first and forwarding to the target from there.

Host bastion
    HostName jump.example.com
    User ops

Host app
    HostName 10.0.0.12
    User deploy
    ProxyJump bastion

Then ssh app goes through the bastion. The trade-off is honest: a bastion concentrates both your protection and your risk, and it is a single point of failure, so harden it harder than anything else and keep a provider console as a fallback. For a single VPS it is usually more machinery than the problem deserves.

Verify, monitor and avoid lockout

Here is the whole rollout in a safe order. Each step is small, reversible and tested from a second terminal before you move on:

  1. Measure the baseline and keep a session plus the provider console open.
  2. Prove key login works, then set PasswordAuthentication no and KbdInteractiveAuthentication no.
  3. Set PermitRootLogin no and AllowUsers with every account you need.
  4. Tighten MaxAuthTries, LoginGraceTime and MaxStartups moderately.
  5. Add fail2ban or ufw limit, with your own address exempt or allowed.
  6. Optionally change the port using two Port lines, then add 2FA or a bastion if the risk justifies it.

After each change run sudo sshd -t, read the result with sudo sshd -T and reload. If a reload breaks something, restore the backup from the session you kept open and reload again.

Then verify the outcome, not just the config. Re-run the baseline commands a day later. Failed attempts will probably still arrive, and that is fine: bots do not read your configuration, they just try. What changed is that none can succeed, the log lines are fewer if you added rate limiting or moved the port, and the only successes are yours. Do not chase zero.

For ongoing monitoring, check these on a schedule, or wire them into the pipeline from the server monitoring and log analysis guide:

sudo journalctl -u ssh --since yesterday --no-pager -o cat -g 'Accepted'
sudo fail2ban-client status sshd
sudo ufw status verbose
last -i -n 20

Alert on two things: a login from an address you do not recognise, and a success after a burst of failures. Everything else is background noise. For the next layers of defence, work through the SSH hardening best practices and the VPS security checklist.

Frequently asked questions

How do I check if someone is brute-forcing my SSH server?

Count failed logins in the journal, for example sudo journalctl -u ssh --since yesterday --no-pager -o cat -g 'Failed password' | wc -l, and list the top source addresses. The unit is sshd on the RHEL family. Lines such as Failed password for invalid user and Invalid user show automated guessing.

What is the best way to stop SSH brute-force attacks?

Disable password login and use keys only, after confirming that key login works from a second terminal. With no password method enabled, guessing cannot succeed. Root login off, an AllowUsers list and rate limiting add depth.

Is changing the SSH port enough to stop brute force?

No. A new port reduces drive-by attempts and quiets the logs, but a port scan finds it quickly and it does nothing against a targeted attacker. Treat it as optional noise reduction on top of key-only login.

Do failed SSH login attempts matter if I only allow keys?

Mostly no. Bots keep trying whatever your configuration says, but with passwords off none of the attempts can succeed. Watch instead for an unexplained successful login, especially right after a burst of failures from the same address.

Should I use fail2ban or ufw limit for SSH?

Either works as a second layer. fail2ban reads logs and bans addresses for a time you choose, while ufw limit denies an address that makes 6 or more connection attempts within 30 seconds, with no extra daemon. Start with one and measure the result.

What are the default MaxAuthTries and LoginGraceTime values?

According to the sshd_config manual, MaxAuthTries is 6 attempts per connection and LoginGraceTime is 120 seconds. MaxStartups defaults to 10:30:100. Run sudo sshd -T to see the values your server actually uses.

How do I avoid locking myself out when hardening SSH?

Keep your current session open, validate with sudo sshd -t, and test every change from a second terminal before closing anything. Know how to reach your provider console in advance, and keep a backup copy of sshd_config.

Can ufw limit block my own SSH connections?

Yes, if you or a script open 6 or more connections to the port within 30 seconds, ufw denies further attempts from that address. Avoid opening connections in rapid loops, or use fail2ban with your own address in ignoreip instead.

Not sure where your server stands?

Get a free, read-only security check, or talk to a security engineer about managed protection.

Get your free server check Talk to a security engineer