Hardening SSH: beyond PasswordAuthentication no

Why the defaults are not enough

Disabling password authentication is table stakes. It's in every hardening guide, it ships as the default on managed cloud images, and yet it's usually the first and last thing people check. The result is an SSH daemon that rejects password auth but still runs Protocol 2 over ciphers from 2008, allows root login with keys, trusts any source IP, and has no session limits.

This guide works through the meaningful controls: the ones that shrink the attack surface, reduce lateral movement radius, and generate signal worth alerting on. Configuration examples target OpenSSH 8.9+ on Linux.

Scope: Server-side sshd_config only. Client-side hardening (~/.ssh/config) is a separate topic.

Cipher suite: cut the legacy algorithms

OpenSSH negotiates the strongest algorithm both sides support. The problem is that "supported" is a wide net — an untuned server will happily agree to arcfour or diffie-hellman-group1-sha1 if the client offers them.

Restrict to algorithms with an actual security argument:

# /etc/ssh/sshd_config

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

The -etm suffix on MACs is important: encrypt-then-MAC prevents the CBC padding oracle class of attacks. Avoid hmac-sha1 entirely even if only used for integrity — the collision resistance argument is shaky enough that there is no reason to include it.

Verify the negotiated algorithms after a reload:

ssh -vv user@host 2>&1 | grep -E 'kex|cipher|mac'

Authentication controls

Disable root login — with keys too

The common advice is PermitRootLogin no. This is correct but incomplete: some guides recommend PermitRootLogin prohibit-password as a "safer" middle ground. That still allows an attacker with a stolen root private key to authenticate directly. The right value is no.

PermitRootLogin no

If you need to run something as root remotely, you log in as a regular user and sudo. This keeps the authorization decision in your PAM/sudoers chain where it belongs.

AuthorizedKeysFile location

The default is .ssh/authorized_keys relative to the user's home directory. This means any process that can write to a user's home can backdoor their SSH access. On shared systems or containers with mounted home directories, this is a real path to persistence.

AuthorizedKeysFile /etc/ssh/authorized_keys/%u

Moving authorized keys to a root-owned directory means only root can modify them. The %u expands to the username. Create the directory and set permissions:

install -d -m 755 /etc/ssh/authorized_keys
# then for each user:
install -m 644 -o root -g root /path/to/pubkey /etc/ssh/authorized_keys/username

AllowUsers / AllowGroups

By default, every user on the system can attempt to authenticate over SSH. Add an explicit allowlist:

AllowGroups sshusers

Then control membership in sshusers through your user management process (or configuration management). Service accounts, application users, and daemon users should never be in this group.

MaxAuthTries and LoginGraceTime

MaxAuthTries 3
LoginGraceTime 20

MaxAuthTries 3 limits key attempts per connection. The default of 6 gives an adversary room to try multiple keys before disconnecting. LoginGraceTime 20 drops a connection that hasn't authenticated within 20 seconds — this limits the half-open connection state an attacker can hold.

Session controls

ClientAliveInterval 300
ClientAliveCountMax 2
MaxSessions 4
MaxStartups 10:30:60

ClientAliveInterval and ClientAliveCountMax together define the idle timeout: 300s × 2 probes = 10 minutes before a dead connection is closed. This matters for compliance but also for resource hygiene — orphaned SSH sessions accumulate on long-running servers.

MaxStartups 10:30:60 is a rate limiter for unauthenticated connections. At 10 pending connections it starts randomly dropping 30% of new attempts; at 60 pending it refuses all new connections. This slows down parallel scanners and brute-force scripts without affecting normal usage patterns.

SFTP: chroot or disable

If you don't use SFTP, disable it. The OpenSSH SFTP server is an attack surface, and there is no reason to expose it if you transfer files via scp or rsync over SSH.

# Comment out or remove:
# Subsystem sftp /usr/lib/openssh/sftp-server

If you do use SFTP for specific users, chroot them:

Match Group sftpusers
    ChrootDirectory /data/sftp/%u
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no

The chroot directory and all its parents must be owned by root with no group-write permission — OpenSSH enforces this at connection time.

Forwarding: off unless needed

AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no

TCP forwarding is a lateral movement vector. An attacker who compromises an SSH session on a bastion host can use forwarding to reach internal services that otherwise aren't accessible. Turn it off globally and enable it only for the specific users or groups that actually need it via Match blocks.

Agent forwarding deserves special attention: it allows a remote host to use your local SSH agent, which means a compromised intermediate host can make key-authenticated connections to other servers as you. Use ProxyJump instead of agent forwarding for jump host patterns — it never exposes the agent to the intermediate host.

Logging and detection

LogLevel VERBOSE

The default INFO level logs authentication events but not the key fingerprints used. VERBOSE adds fingerprint logging, which lets you correlate an authentication event with a specific key even when you don't know in advance which key was used.

Useful log patterns to alert on:

# Failed public key (key offered but rejected)
grep "Failed publickey" /var/log/auth.log

# Successful login from an unexpected source
grep "Accepted publickey" /var/log/auth.log | awk '{print $9}' | sort | uniq -c | sort -rn

# Multiple MaxAuthTries hits (scanning or brute force)
grep "Disconnecting.*Too many authentication" /var/log/auth.log

Going further: SSH certificate authentication

Authorized keys scale poorly. Every key is manually provisioned and manually revoked — a terminated employee's key needs to be removed from every server. SSH certificates solve this with a CA model: users and hosts get certificates signed by a CA, and you revoke access by updating the CA configuration rather than modifying individual authorized_keys files.

# Generate CA key (store offline or in HSM)
ssh-keygen -t ed25519 -f /etc/ssh/ssh_ca -C "infrastructure CA 2026"

# Sign a user key (valid 8 hours, for specific principals)
ssh-keygen -s /etc/ssh/ssh_ca \
  -I "alice@example.com 2026-11-07" \
  -n alice,deploy \
  -V +8h \
  alice_id_ed25519.pub

# Trust the CA on each server
echo "TrustedUserCAKeys /etc/ssh/ssh_ca.pub" >> /etc/ssh/sshd_config

Certificate validity windows (the -V +8h above) eliminate the stale-key problem: a certificate issued for a 4-hour maintenance window expires automatically and can't be reused. This is a significant improvement over key-based auth where a compromised key remains valid indefinitely until manually removed.

The tradeoff is operational complexity: you need a CA, a certificate issuance workflow (often integrated with Vault or similar), and a process for host certificate distribution. Worth it at scale; overkill for a 5-server deployment where you can keep the authorized_keys inventory tight.

Full reference configuration

# /etc/ssh/sshd_config — hardened baseline (OpenSSH 8.9+)

Port 22
Protocol 2
AddressFamily inet  # or inet6; avoid 'any' on dual-stack hosts

HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key

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

# Authentication
PermitRootLogin no
StrictModes yes
MaxAuthTries 3
PubkeyAuthentication yes
AuthorizedKeysFile /etc/ssh/authorized_keys/%u
PasswordAuthentication no
ChallengeResponseAuthentication no
UsePAM yes

# Allowlist
AllowGroups sshusers

# Sessions
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2
MaxSessions 4
MaxStartups 10:30:60

# Forwarding
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
PermitTunnel no

# Logging
LogLevel VERBOSE
SyslogFacility AUTH

# SFTP (remove if unused)
# Subsystem sftp /usr/lib/openssh/sftp-server

Before applying: test with sshd -t (syntax check) and keep an active session open while reloading with systemctl reload sshd. A syntax error in a reloaded config will refuse new connections but won't kill existing sessions — your open session is your recovery path.