SSH Hardening: A Practical Guide for Production Servers

SSH is one of the first services I lock down on a new server because it is usually the front door to everything else. If SSH is weak, the rest of your hardening work starts from a bad premise. If SSH is solid, you at least force attackers into harder, noisier, or less direct paths.

That said, SSH hardening is full of cargo-cult advice. People still recommend changing the port as if it were a security strategy, paste old cipher lists without checking what their OpenSSH version supports, or disable passwords before testing key auth from a second session. I have seen all of those create avoidable pain.

This guide is the version I actually follow on production Linux servers. It is practical, a little conservative, and biased toward changes I can validate without locking myself out.

If you need the full key setup flow first, read the SSH key authentication guide before changing sshd_config. For brute-force blocking and ban rules, the Fail2ban setup guide fits naturally with the steps below.

Start with a rule that saves outages

Before you touch /etc/ssh/sshd_config, open a second session or keep an out-of-band console available.

Then validate syntax before restart:

sudo sshd -t

And when you reload, do not close your current session until you have confirmed a new login works.

That sounds obvious. It is still the step people skip when they are in a hurry.

Key authentication first, passwords off second

The secure order is:

  1. get public-key authentication working
  2. test it from a fresh session
  3. only then disable password authentication

I do not disable passwords on a server until I know at least two maintained admin paths can log in with keys.

Confirm the server accepts keys

Client-side verbose testing tells you a lot quickly:

ssh -vvv admin@example.com

Server-side, I check file permissions and key location:

chmod 700 /home/admin/.ssh
chmod 600 /home/admin/.ssh/authorized_keys
chown -R admin:admin /home/admin/.ssh

Then I confirm the server settings align with that:

PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys

If you use a custom path for authorized keys, document it clearly. I prefer the default unless there is a strong reason to do otherwise.

The sshd_config directives I care about most

On Debian, Ubuntu, AlmaLinux, Rocky, or similar systems, the main file is usually /etc/ssh/sshd_config with optional drop-ins under /etc/ssh/sshd_config.d/.

I increasingly prefer a small drop-in file rather than editing a vendor-shipped base file directly.

Example:

sudo install -d -m 0755 /etc/ssh/sshd_config.d
sudo nano /etc/ssh/sshd_config.d/hardening.conf

A practical baseline:

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
AllowTcpForwarding no
PermitEmptyPasswords no
UsePAM yes

That is not the only possible baseline, but it is a strong starting point for typical production hosts.

PermitRootLogin: no or prohibit-password?

I usually set:

PermitRootLogin no

That forces administrators to log in as named users and use sudo when necessary. It improves accountability and removes a huge amount of direct root noise from logs.

There are cases where people use:

PermitRootLogin prohibit-password

That allows root login with keys but not passwords. It is better than full root password access, but on most normal servers I still prefer no.

The main exception is emergency access patterns in tightly controlled environments. Even then, I try to justify it explicitly rather than carry it forward out of habit.

PasswordAuthentication: disable it once keys are proven

For production internet-facing servers, my steady state is:

PasswordAuthentication no

I also generally turn off challenge-response style password prompts when not using them:

KbdInteractiveAuthentication no

If you later enable MFA with PAM, you may need to turn keyboard-interactive back on as part of the design. Do not copy this block blindly into a two-factor rollout without checking the authentication method chain.

Restrict who may log in

Allowing every local account to attempt SSH login is sloppy.

I prefer explicit allow-lists.

AllowUsers

AllowUsers admin deploy backup

This is simple and direct. It is a good fit for smaller hosts.

AllowGroups

AllowGroups sshadmins sftpusers

On larger systems or when user management is delegated, I like groups better because account changes do not require repeated edits to SSH policy.

You can also combine the two, but I try not to overcomplicate it. The more conditions you stack, the more likely someone will misread the effective policy later.

If you need background on disciplined account and group management, the Linux user and group management guide is a good companion.

Should you change the default SSH port?

This is the most over-argued SSH topic on the internet.

My view is simple:

  • changing the port does not replace real security controls
  • changing the port does reduce automated noise and cheap scans

So yes, it can help operationally. No, it is not a serious security boundary.

If I change the port, it is mostly to cut log spam and lower low-effort brute-force traffic. That can be worthwhile.

Example:

Port 2222

Then update the firewall before reloading SSH:

sudo ufw allow 2222/tcp
sudo ufw delete allow 22/tcp

Or with firewalld:

sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reload

I only remove the old port after I confirm the new one works from a fresh session.

Protocol 2 and the old Protocol directive

If you still see this in configs:

Protocol 2

that is a reminder of how old some examples are.

SSH protocol version 1 is ancient and insecure. Modern OpenSSH has long since moved past it, and many current versions do not need or even accept the old Protocol directive because protocol 2 is effectively the only relevant option.

So the practical advice is:

  • if a very old config still mentions Protocol 2, it reflects the correct security intent
  • for modern OpenSSH, do not panic if the directive is absent
  • do not waste time trying to “disable Protocol 1” on systems where it no longer exists

This is a good example of why blindly copying old hardening snippets is not the same as understanding them.

Ciphers, KEX, and MACs: harden carefully

Algorithm hardening matters, but it is one of the easiest places to break older automation or appliances.

Before changing anything, I check what the installed client supports:

ssh -Q cipher
ssh -Q kex
ssh -Q mac

A modern server-side example might look like this:

KexAlgorithms sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes128-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com

I use examples like that as a starting point, not as doctrine. The exact final list depends on:

  • OpenSSH version
  • compliance requirements
  • compatibility needs with managed devices, jump hosts, or older clients

Algorithms I remove without much regret

If I still find them enabled on older systems, I plan to phase out:

  • CBC-mode ciphers
  • MD5-based MACs
  • SHA-1-heavy legacy combinations where avoidable
  • tiny Diffie-Hellman groups

The main mistake here is going from “old default” straight to “aggressively modern” on a production estate without testing every automation path.

MaxAuthTries and LoginGraceTime

These two settings are small but useful.

MaxAuthTries 3
LoginGraceTime 30

MaxAuthTries reduces repeated guessing in a single connection. LoginGraceTime limits how long a connection can sit before successful authentication.

I do not set them absurdly low. Humans and automation both benefit from a little margin.

Idle sessions: ClientAliveInterval and ClientAliveCountMax

Long-dead SSH sessions are messy, especially behind flaky NAT or VPN paths.

I usually set:

ClientAliveInterval 300
ClientAliveCountMax 2

That means the server checks the client every 300 seconds and disconnects after two missed responses.

This is not purely a security setting. It is also housekeeping. I have seen bastion hosts accumulate stale sessions for hours otherwise.

Disable features you do not use

OpenSSH is flexible, which is useful right up until unnecessary features become unnecessary risk.

X11Forwarding

If the server does not need X11 forwarding:

X11Forwarding no

That is the default in my hardening set unless I have a specific legacy workflow to support.

AllowTcpForwarding

If users do not need SSH tunnels through this server:

AllowTcpForwarding no

That closes off a feature that can otherwise be used for perfectly legitimate admin work or for bypassing network controls.

Of course, some environments genuinely need forwarding. If yours does, do not disable it just because a hardening checklist said so. The SSH port forwarding and tunneling guide covers the legitimate use cases and why this setting needs deliberate thought.

PermitTunnel, GatewayPorts, and friends

I also review these when I audit a bastion or jump host. If you do not know why they are enabled, they probably should not be.

Banner messages

A legal or warning banner is not a hardening control, but it can be a policy requirement.

Example:

Banner /etc/issue.net

And then:

sudo tee /etc/issue.net > /dev/null <<'EOF'
Authorized access only. Activity may be monitored and logged.
EOF

I keep banners short and unambiguous. Huge paragraphs help nobody.

Host keys and fingerprint verification still matter

User authentication gets most of the attention, but I also care about how clients verify the server.

After rebuilding or rotating a host, I check its presented host keys explicitly:

sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
sudo ssh-keygen -lf /etc/ssh/ssh_host_rsa_key.pub

From a client side, I verify what the network path actually presents:

ssh-keyscan -p 2222 example.com

This helps catch the very practical problems: stale bastion documentation, old known_hosts entries, or traffic landing on the wrong box after a rebuild. Hardening is not only about rejecting bad users. It is also about letting good users verify they reached the right server.

Add two-factor authentication when the risk justifies it

For higher-value systems, I like requiring MFA for SSH, especially on bastions.

One common approach on Linux is Google Authenticator PAM support.

Install the module on Debian or Ubuntu:

sudo apt update
sudo apt install -y libpam-google-authenticator

Then a user initializes their secret:

google-authenticator

PAM configuration usually involves adding a line such as this to /etc/pam.d/sshd:

auth required pam_google_authenticator.so

And enabling keyboard-interactive auth in SSH:

KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive

That combination forces a key plus a one-time code.

Common MFA rollout mistake

The common failure here is enabling AuthenticationMethods publickey,keyboard-interactive before confirming every admin actually enrolled a token.

I test with a non-root admin account first, confirm console access exists, and only then widen the rollout.

Fail2ban is still worth using

Even after disabling passwords, I still use Fail2ban on exposed SSH services. It cuts noise, blocks low-effort repeated abuse, and gives me another layer that is easy to understand.

A minimal install on Debian or Ubuntu:

sudo apt update
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban

Then verify jails:

sudo fail2ban-client status
sudo fail2ban-client status sshd

Fail2ban is not magic, but it is useful friction. The detailed setup choices are in the Fail2ban practical guide.

Audit SSH access regularly

Hardening is not finished when the config file looks pretty.

I audit logins with simple tools first.

Recent successful logins:

last -a | head -n 20

Failed logins on Debian or Ubuntu:

sudo grep 'Failed password\|Invalid user' /var/log/auth.log | tail -n 50

On systemd-journald-heavy systems:

sudo journalctl -u ssh -n 100 --no-pager

Or, depending on distro naming:

sudo journalctl -u sshd -n 100 --no-pager

I look for:

  • unexpected usernames
  • logins from unusual source IPs
  • repeated authentication failures
  • successful logins at strange times
  • automation still using password auth after policy changes

SSH certificate authentication is worth considering

For larger estates, SSH certificates can be cleaner than managing sprawling authorized_keys files on every server.

Instead of copying individual public keys everywhere, you trust a signing CA key on servers and issue short-lived user certificates.

High-level server config:

TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem

Then place the CA public key there:

sudo install -m 0644 trusted-user-ca-keys.pem /etc/ssh/trusted-user-ca-keys.pem

User cert issuance happens separately with ssh-keygen -s, for example:

ssh-keygen -s user_ca -I admin-laptop -n admin -V +8h ~/.ssh/id_ed25519.pub

I like SSH certificates because they solve revocation and short-lived access much more cleanly than “remember to delete old authorized keys everywhere.” They are not necessary for every small environment, but once you manage lots of hosts and users, they start making more sense.

Validation steps after every hardening change

My post-change checklist is predictable:

  1. validate config syntax
  2. reload SSH
  3. test a fresh session
  4. test sudo access
  5. test automation if relevant
  6. confirm logs look normal

Commands:

sudo sshd -t
sudo systemctl reload sshd
ssh -p 2222 admin@example.com
sudo journalctl -u sshd -n 50 --no-pager

On Debian-based systems the service might be ssh instead of sshd:

sudo systemctl reload ssh

I always check the actual unit name before scripting changes across a fleet.

A practical hardened example

For a typical internet-facing application server with key auth only, no root login, no tunnels, and moderate idle timeouts, I might end up with something like this:

Port 2222
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
AllowGroups sshadmins
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
AllowTcpForwarding no
PermitEmptyPasswords no
UsePAM yes

It is not exotic. That is the point. Reliable hardening beats creative hardening.

Final thoughts

SSH hardening works best when it is treated as disciplined risk reduction, not as a collection of internet myths. Key authentication matters more than port changes. Explicit access control matters more than copy-pasted banners. Modern algorithms matter, but only if you test them against real clients. MFA helps, but only when rolled out carefully. And logs still matter because a hardened service nobody audits can still become a blind spot.

My practical pattern is simple: use named accounts, disable direct root access, get keys working before disabling passwords, restrict who can log in, disable features you do not need, add Fail2ban, and validate every change from a second session before you call it done.

That baseline is not flashy. It is just the set of controls I have found most effective at reducing real SSH risk without making servers impossible to operate.

Scroll to Top