SSH Hardening
Topic 64

SSH Hardening

Security

SSH is the front door to every server you run, and the stock sshd_config that ships on Debian and Ubuntu is tuned for getting in, not for keeping attackers out. It accepts passwords, often permits root, listens on every interface, and offers a wide menu of key-exchange and cipher algorithms for backward compatibility. Anything reachable on port 22 from the public internet starts collecting credential-stuffing attempts within minutes — the only open question is whether one of them eventually guesses a password.

Hardening SSH is about removing the cheap attacks: turn off password authentication so brute force has nothing to chew on, deny root a direct login so a stolen key still needs a second escalation, and restrict who may even attempt a session. Done correctly it converts the most exposed service on the box from a guessing game into a problem that requires stealing a private key — a far harder bar. Done carelessly, the same edits lock you out of a remote machine that has no console.

Key-Based Authentication

A password is something an attacker can guess at thousands of attempts per minute against an exposed port. A key pair is not: the private key never crosses the wire, and a modern key holds far more entropy than any password a human will type. The single highest-value change you can make is to stop accepting passwords entirely, so that no amount of guessing against port 22 can ever succeed. Public keys go in the user's ~/.ssh/authorized_keys; the server verifies a signature instead of comparing a secret.

Generate ed25519 keys, not RSA. Ed25519 is a fixed 256-bit elliptic-curve key — small, fast to verify, and free of the "is 2048 bits still enough?" question that dogs RSA. Use RSA only when you must talk to something ancient that predates ed25519 support, and then never below 3072 bits. The relevant sshd_config directives:

# /etc/ssh/sshd_config.d/10-auth.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey

Turning off PasswordAuthentication alone is not always enough: PAM-driven keyboard-interactive auth is a separate path, which is why KbdInteractiveAuthentication no (formerly ChallengeResponseAuthentication) sits beside it. Generate the client key with ssh-keygen -t ed25519 -C "you@host", push it with ssh-copy-id, and confirm a key login works before you disable passwords. On Red Hat and its derivatives the directives are identical; only the SELinux context around authorized_keys (the ssh_home_t label) and the firewall front end differ.

Privilege and Access Controls

Root is the account every attacker wants and the only one whose name they already know. A direct root login over SSH collapses two barriers into one: compromise the key or password and you are immediately the superuser, with nothing recorded about which human did it. Set PermitRootLogin no and require admins to log in as themselves, then escalate with sudo — which leaves an audit trail tied to a real user. If automation genuinely needs root over SSH, use prohibit-password (key-only root) rather than yes, and never a root password.

Default sshd lets every account with a shell attempt a login. Narrow that to an explicit allowlist with AllowUsers or AllowGroups, so a forgotten service account or a newly created user cannot become an SSH entry point by accident. An allowlist fails closed: anyone not named is refused before authentication even begins.

# /etc/ssh/sshd_config.d/20-access.conf
PermitRootLogin no
AllowGroups ssh-users
MaxAuthTries 3
LoginGraceTime 20

# tighter rules for one group, applied after the global block
Match Group sftp-only
    ForceCommand internal-sftp
    ChrootDirectory %h
    X11Forwarding no
    AllowTcpForwarding no

A Match block applies a subset of directives to a specific user, group, or source address, and is the right tool for "SFTP users get a chroot jail, admins do not." Order matters: directives inside a Match override the global ones for matching connections, and a Match block runs until the next Match or the end of the file. Pair it with MaxAuthTries 3 to cut off a connection after three failed attempts and LoginGraceTime 20 so half-open auth sessions cannot pile up.

Network-Level Hardening

Moving SSH off port 22 to a high port stops the noise of untargeted scanners hitting the default, which makes logs quieter — but it is obfuscation, not security. A scanner that fingerprints all 65,535 ports finds it in seconds, and a non-standard port complicates your own tooling, firewall rules, and team's muscle memory. Treat a custom port as log hygiene at best, never as a control you rely on. The real network controls are a firewall that limits who can reach the port and a tool that bans hosts that misbehave.

Restrict the source with the host firewall. On Ubuntu, ufw wraps nftables; the rules below allow SSH only from an admin subnet and rate-limit anything else. Better still, do not expose SSH to the public internet at all — put it behind a VPN or a bastion host so the port is unreachable from the open internet.

# allow SSH only from the office/VPN range
ufw allow from 10.8.0.0/24 to any port 22 proto tcp
# rate-limit anything else that reaches it (6 hits in 30s = deny)
ufw limit 22/tcp

For hosts that must stay internet-facing, fail2ban watches the auth log and inserts a firewall ban after a threshold of failed logins — its sshd jail defaults to 5 failures earning a 10-minute block, which you can raise toward a permanent one. With password auth already disabled this matters less, but it still kills the connection churn from bots and slows anyone probing key auth. On Red Hat the firewall front end is firewalld rather than ufw; fail2ban itself is identical across distributions.

Config Hygiene and Key Management

Editing the giant default /etc/ssh/sshd_config in place makes upgrades painful: package updates may ship a new default file, and your changes get buried among commented examples. Current Debian and Ubuntu ship a sshd_config.d/ directory that the main file pulls in with Include /etc/ssh/sshd_config.d/*.conf. Drop your changes into small, named files there — one concern per file — and leave the stock config untouched. Validate every change with sshd -t before reloading, because a syntax error that crashes sshd on restart can lock you out of a remote box.

Audit the live configuration with ssh-audit, an external tool that connects and reports weak key-exchange algorithms, ciphers, and MACs the server still offers — the legacy diffie-hellman-group14-sha1 exchange and CBC-mode ciphers being the usual offenders. It prints concrete directives to paste in. Host keys deserve attention too: the server's host keys are what clients pin in known_hosts, so rotating them forces every client to re-verify, and a host key that leaked — snapshot a VM image with its keys baked in, clone it, and now a dozen servers share one identity — must be regenerated per machine by deleting /etc/ssh/ssh_host_* and then running ssh-keygen -A, which recreates only the key types that are now missing.

Common Mistakes
  • Disabling PasswordAuthentication or restarting sshd without an existing session held open and a tested key login — one typo or a missing authorized_keys entry locks you out of a machine that has no console.
  • Adding keys but leaving PasswordAuthentication yes — the keys do nothing to close the brute-force surface, because the password path is still open and still guessable.
  • Leaving PermitRootLogin yes (the historical default on many images) — a single compromised credential lands the attacker at root immediately, with no record of which person it was.
  • Trusting an obscure port as protection — moving off 22 quiets the logs but stops nothing; a port scan finds the new port in seconds.
  • Never setting AllowUsers/AllowGroups, so every account with a shell — including stale service accounts — is a valid SSH target.
  • Exposing port 22 to 0.0.0.0 with no firewall source restriction, VPN, or bastion — the front door faces the whole internet when it only ever needs to face a handful of admin IPs.
  • Cloning a VM image with its host keys baked in, so many servers share one host identity and known_hosts pinning stops meaning anything.
Best Practices
  • Generate ed25519 keys with ssh-keygen -t ed25519; fall back to RSA 3072+ only for legacy peers that lack ed25519 support.
  • Set PasswordAuthentication no and KbdInteractiveAuthentication no together, and confirm a key login works before you reload sshd.
  • Set PermitRootLogin no and make admins log in as themselves, then escalate with sudo for an auditable trail.
  • Restrict logins with AllowGroups so the allowlist fails closed, and scope per-role rules with Match blocks.
  • Keep SSH off the public internet behind a VPN or bastion; where it must be exposed, restrict the source with ufw/firewalld and run fail2ban.
  • Put every change in a named file under /etc/ssh/sshd_config.d/, validate it with sshd -t, and never edit the stock config in place.
  • Audit the running server with ssh-audit to strip weak ciphers and KEX, and keep a second SSH session open during any change so a mistake is recoverable.
Comparable toolsWindows — RDP for graphical sessions, plus the OpenSSH server now bundled with Windows; key auth and source restriction carry over directlymacOS — Remote Login is the same OpenSSH daemon, toggled in System Settings and configured through an identical sshd_configMosh / Teleport / bastion hosts — a roaming UDP shell, an access proxy issuing short-lived certs, and the jump-host pattern that keeps sshd off the open internet

Knowledge Check

You add SSH keys to a server but leave PasswordAuthentication yes. What is the security outcome?

  • The brute-force surface is unchanged — attackers can still guess passwords on port 22; the keys only help the users who use them
  • Password login is automatically disabled by sshd for any account that has at least one valid authorized public key installed
  • The server now requires both a valid key and a correct password together for every single login, effectively doubling the protection
  • Password guesses are now rejected outright at the door because public-key authentication always takes strict priority over them

Why is moving sshd from port 22 to a high random port a weak security control?

  • It is obfuscation, not authentication — a port scan finds the new port quickly, and it complicates your own firewall rules and tooling
  • High ports are blocked outright by most ISPs and corporate egress firewalls, so your own legitimate clients can no longer reach the server either
  • The kernel only ever enforces its built-in access controls on the privileged ports below 1024, leaving any high port entirely unprotected
  • Changing the listening port silently disables host-key verification on the client side for every connection to that host

What is the operational reason to set PermitRootLogin no and require sudo instead?

  • A stolen credential then lands on a named user who still needs a second escalation, and the trail records which human acted
  • Direct root logins are measurably slower for the user because the kernel has to re-verify the key on every single command issued
  • It is the only supported way to enable key-based authentication at all for privileged administrator and operator accounts
  • Disabling root login automatically encrypts every single account's authorized_keys file at rest on the disk

Why put changes in /etc/ssh/sshd_config.d/ drop-ins and run sshd -t before reloading?

  • Drop-ins survive package upgrades cleanly, and sshd -t catches a syntax error that would otherwise crash the daemon and lock you out remotely
  • Drop-in files are the only location sshd will ever read user authentication keys from on a modern Debian or Ubuntu system today
  • sshd -t directly applies the validated new configuration live without any reload step, so the change incurs no downtime on the host at all
  • The main sshd_config file is now shipped strictly read-only on a modern Ubuntu host and cannot be opened and edited directly by anyone at all

You got correct