SSH is the one service that is open on almost every Linux server and the one whose compromise hands over everything else. The defaults on a fresh RHEL-family install are reasonable but not strict: root can log in with a key, passwords are accepted, idle sessions live forever, and any local account is a valid SSH user.
This guide is a hardening pass for CentOS Stream 9, AlmaLinux 8/9 and Rocky Linux 8/9 (and RHEL itself). The original version targeted CentOS 7 and 8; both are end-of-life now (CentOS 8 in December 2021, CentOS 7 in June 2024) and should be migrated, but every step still works on them — differences are noted inline. Each step ends with how to verify it, and the whole thing is designed so that you never close a working session until the next one is proven.
Rule for the whole guide: keep your current SSH session open. After every change, open a new terminal and confirm you can still log in. Only then continue.
Where the configuration lives
- RHEL/Alma/Rocky 9, CentOS Stream 9:
/etc/ssh/sshd_configincludes/etc/ssh/sshd_config.d/*.conf. Put your changes in a drop-in such as/etc/ssh/sshd_config.d/10-hardening.conf. Files are read in lexical order and the first occurrence of a directive wins, so a low number beats the vendor's50-redhat.conf. - RHEL/Alma/Rocky 8, CentOS 7/8: no drop-in directory by default; edit
/etc/ssh/sshd_configdirectly (take a backup:sudo cp /etc/ssh/sshd_config{,.bak}).
Two commands you will use after every step:
sudo sshd -t # syntax check: silent = OK
sudo sshd -T | grep -i <key> # the *effective* value after all includes
sudo systemctl reload sshd # apply without dropping existing sessions
Step 1: Create a dedicated login user with sudo
Log in as root only via the console. Day-to-day access goes through a personal account that can sudo:
sudo useradd -m -G wheel deploy
sudo passwd deploy
wheel is the sudo group on RHEL-family systems; the stock /etc/sudoers already grants it. Details and the NOPASSWD variants are in Add a user to sudoers on AlmaLinux/Rocky Linux.
Verify: ssh deploy@server and sudo -v both succeed.
Step 2: Install your SSH key for that user
From your workstation:
ssh-keygen -t ed25519 -a 64 -C "you@workstation"
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@server
ssh-copy-id creates ~/.ssh (700) and authorized_keys (600) with the right modes and, on SELinux systems, the right context. If you copy the key by hand, run restorecon -Rv ~deploy/.ssh afterwards — a wrong SELinux label is the most common "key works for root but not for the new user" cause on RHEL.
Verify: ssh deploy@server logs in without asking for the account password.
Step 3: Disable root login
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'CONF'
PermitRootLogin no
CONF
sudo sshd -t && sudo systemctl reload sshd
prohibit-password (the upstream default) still allows root with a key; no closes it entirely. If an automation account genuinely needs root over SSH, give it its own user with a narrow sudoers rule instead.
Verify: ssh root@server → Permission denied.
Step 4: Disable password and empty-password authentication
Append to the same drop-in:
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PubkeyAuthentication yes
(ChallengeResponseAuthentication is the old name for KbdInteractiveAuthentication; use the old name on CentOS 7.) Reload and verify from a second terminal:
ssh -o PubkeyAuthentication=no deploy@server
# Permission denied (publickey).
This step alone ends SSH brute-forcing. The remaining steps limit blast radius and noise; see 5 ways to stop SSH brute-force attacks for fail2ban and firewall rate-limiting on top.
Step 5: Limit who can log in at all
AllowGroups ssh-users
sudo groupadd ssh-users
sudo usermod -aG ssh-users deploy
sudo sshd -t && sudo systemctl reload sshd
Now postgres, nginx, forgotten test accounts and anything an attacker creates later are not valid SSH users, regardless of their keys or passwords. AllowUsers deploy backup@203.0.113.0/24 is the per-user form, with an optional source restriction.
Verify: sudo sshd -T | grep allowgroups, and a login as a user not in the group is refused.
Step 6: Idle timeout and connection limits
ClientAliveInterval 300
ClientAliveCountMax 2
LoginGraceTime 20
MaxAuthTries 3
MaxSessions 4
ClientAliveInterval 300+ClientAliveCountMax 2: the server probes an idle client every 5 minutes and drops it after two unanswered probes (≈10 minutes idle). This closes sessions on unattended laptops; it does not log out an activevim.LoginGraceTime 20: unauthenticated connections are dropped after 20 s.MaxAuthTries 3: three attempts per connection.
Step 7: Trim the feature set
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
Banner /etc/issue.net
Turn AllowTcpForwarding back on for specific users with a Match User block if they need port forwarding (database tunnels, ProxyJump through a bastion):
Match User deploy
AllowTcpForwarding yes
Match blocks must come last in the file; everything after a Match line belongs to it.
Protocol 2 and ciphers
Protocol 2 is the only option in every OpenSSH release of the last decade; the directive is accepted but meaningless on 8/9 (on CentOS 7 it is already the default). Cipher and key-exchange policy on RHEL 8/9 is managed system-wide by crypto-policies — sudo update-crypto-policies --set FUTURE tightens SSH, TLS and Kerberos together — rather than by hand-editing Ciphers and KexAlgorithms, which the policy would override anyway.
Step 8 (optional): Change the port — properly
Moving off port 22 quiets the logs and defeats the laziest scanners. It is not a security control, and on RHEL it breaks in two places if done naively.
# 1. sshd
echo 'Port 2222' | sudo tee -a /etc/ssh/sshd_config.d/10-hardening.conf
# 2. SELinux must allow sshd to bind the new port
sudo dnf install -y policycoreutils-python-utils # provides semanage
sudo semanage port -a -t ssh_port_t -p tcp 2222 # use -m instead of -a if it says the port is already defined
# 3. firewalld must let it in (keep 22 open until the new port is verified!)
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --reload
sudo sshd -t && sudo systemctl restart sshd
Verify from a second terminal with ssh -p 2222 deploy@server. Only then remove port 22: sudo firewall-cmd --permanent --remove-service=ssh && sudo firewall-cmd --reload. If sshd fails to start after adding the port, sudo journalctl -u sshd -n 20 will show Permission denied from SELinux — step 2 was skipped. On RHEL 9, sshd is socket-activated on some images; if Port seems ignored, check systemctl status sshd.socket.
The finished drop-in
# /etc/ssh/sshd_config.d/10-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PubkeyAuthentication yes
AllowGroups ssh-users
ClientAliveInterval 300
ClientAliveCountMax 2
LoginGraceTime 20
MaxAuthTries 3
MaxSessions 4
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
Banner /etc/issue.net
# Port 2222
Match User deploy
AllowTcpForwarding yes
Final check that the running daemon agrees with the file:
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|allowgroups|clientalive|maxauthtries|^port'
Troubleshooting
sshd -tis silent but a setting has no effect — another file sets it first.sudo sshd -T | grep -i <key>shows the effective value;grep -ri <key> /etc/ssh/sshd_config /etc/ssh/sshd_config.d/shows where.- Key refused for the new user:
Authentication refused: bad ownership or modes—chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys; chown -R deploy:deploy ~deploy/.ssh, and the home directory itself must not be group- or world-writable. - Key refused only on RHEL, modes are correct — SELinux label:
restorecon -Rv ~deploy/.ssh.sudo ausearch -m avc -ts recentconfirms. - Locked out — use the provider console, log in as root locally, and relax the offending line. Then re-read the rule at the top of this guide.
Connection refusedafter a port change — SELinux or firewalld, see step 8.sudo ss -tlnp | grep sshdshows whether sshd is actually listening on the new port.- CentOS 7:
KbdInteractiveAuthenticationunknown — useChallengeResponseAuthentication no;sshd -ttells you about unknown directives.
Summary
- Personal user in
wheel; root stays on the console. - Ed25519 key installed with
ssh-copy-id. PermitRootLogin no,PasswordAuthentication no,PermitEmptyPasswords no.AllowGroups ssh-users.- Idle timeout,
LoginGraceTime,MaxAuthTries. - Forwarding and X11 off, re-enabled per user with
Match. - Port change only with
semanageandfirewall-cmd; keep 22 open until verified. sshd -tbefore every reload,sshd -Tto confirm, a second terminal before you close the first.
Related Resources
- 5 ways to stop SSH brute-force attacks on Linux — fail2ban, firewall allow-lists and rate limiting on top of this configuration
- Add a user to sudoers on AlmaLinux/Rocky Linux — every shape of the sudo grant for the account you created in step 1
- How to set up TCP uptime monitoring — a TCP check on the SSH port tells you within a minute if a reload took the daemon down
- Linux server monitoring — the host-level view: logins, load and connections after the hardening pass