Audit Your Current SSH Configuration Before Touching It
Before making changes, get a baseline. The tool ssh-audit by jtesta gives you a machine-readable report of every cipher, MAC, key exchange algorithm, and host key in use, along with CVE references for known-weak primitives.
Install it from PyPI or pull the standalone binary. On our test server running a default Ubuntu 24.04 install, ssh-audit flagged three deprecated MACs (hmac-sha1, hmac-sha1-etm, umac-64-etm) and two weak key exchange algorithms (diffie-hellman-group14-sha1, diffie-hellman-group-exchange-sha1) as fail-level findings.
Run it against your server from a remote machine so you see exactly what an attacker sees:
Also check your current sshd_config for any Include directives pulling in drop-in files from /etc/ssh/sshd_config.d/ - Ubuntu 24.04 ships with a 50-cloud-init.conf file there that re-enables PasswordAuthentication unless you override it explicitly.
pip3 install ssh-audit
ssh-audit your-server-ip
# Or run against a non-default port
ssh-audit -p 2222 your-server-ip
Disable Root Login and Password Authentication First
These two settings eliminate the majority of automated attack surface. Root login gives an attacker a known username to target and bypasses sudo audit trails. Password authentication exposes you to credential stuffing from breached credential databases.
Edit /etc/ssh/sshd_config and set:
On Ubuntu 24.04, also check /etc/ssh/sshd_config.d/50-cloud-init.conf. If it contains PasswordAuthentication yes, either delete the file or override it by creating /etc/ssh/sshd_config.d/99-hardening.conf with PasswordAuthentication no - files are processed in alphabetical order and later values win.
After changing these settings, do not restart sshd yet. Keep your current session open, open a second terminal, and test that your key-based login works before you lock yourself out. We have seen this exact mistake cause emergency console sessions on three separate client systems in the past year.
To verify before restarting:
# In /etc/ssh/sshd_config or /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
UsePAM yes
# Test config syntax before reloading
sshd -t
# If clean, reload
systemctl reload sshd
Lock Down Ciphers, MACs, and Key Exchange Algorithms
OpenSSH negotiates the strongest mutually supported option, but "mutually supported" includes whatever ancient clients request. Explicitly whitelisting modern algorithms removes that negotiation space entirely.
The following configuration reflects current NIST SP 800-57 and BSI TR-02102-4 guidance as of 2026. ChaCha20-Poly1305 and AES-GCM with ETM (encrypt-then-MAC) are the only cipher suites we permit. For key exchange, curve25519 and the ECDH NIST curves with SHA-512 are acceptable; all diffie-hellman-group14 variants are removed.
For host keys, Ed25519 is preferred. RSA keys are acceptable only at 4096 bits. Remove DSA and ECDSA host keys from /etc/ssh/:
After setting these, re-run ssh-audit. Our test server went from 6 fail-level findings to 0, with only one info-level note about the RSA host key size (which disappears once you drop to Ed25519-only).
If you have legacy systems that cannot negotiate these ciphers, isolate them to a separate sshd instance on a different port bound to a specific internal interface, rather than weakening the primary configuration.
# Add to /etc/ssh/sshd_config.d/99-hardening.conf
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
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp521,ecdh-sha2-nistp384
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
PubkeyAcceptedAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256
# Remove weak host keys
rm /etc/ssh/ssh_host_dsa_key* /etc/ssh/ssh_host_ecdsa_key* 2>/dev/null
# Regenerate Ed25519 host key if not present
[[ -f /etc/ssh/ssh_host_ed25519_key ]] || ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
# Set HostKey directives explicitly
# HostKey /etc/ssh/ssh_host_ed25519_key
# HostKey /etc/ssh/ssh_host_rsa_key
Restrict Access with AllowUsers, AllowGroups, and Network Rules
AllowUsers and AllowGroups are the SSH daemon's built-in access control layer. If a user is not listed, they cannot authenticate regardless of key validity. This matters for service accounts that have valid keys but should never get an interactive shell.
Create a dedicated group for SSH access:
The Match block in sshd_config lets you override global settings per user, group, host, or address. Use this to allow a jump host user from a specific IP range with more permissive settings, while keeping the global config locked down.
At the network layer, use ufw or nftables to restrict port 22 to known IP ranges where possible. For servers that need broad access, at minimum rate-limit new connections:
If your team uses a VPN for management access - NordVPN's Linux client (https://nordvpn.com/?ref=PLACEHOLDER) supports a CLI-only install with nordvpn connect and meshnet routing, which works well for creating a stable IP range you can whitelist in your firewall rules, even across team members with dynamic home IPs.
# Create SSH access group
groupadd sshusers
usermod -aG sshusers deploy
usermod -aG sshusers alice
# In sshd_config
AllowGroups sshusers
# Match block example - allow jump user only from internal range
Match User jumphost Address 10.0.0.0/8
AllowTcpForwarding yes
X11Forwarding no
ForceCommand /usr/local/bin/jump-audit-wrapper
# nftables rate limiting (add to existing ruleset)
nft add rule inet filter input tcp dport 22 ct state new limit rate 5/minute accept
nft add rule inet filter input tcp dport 22 ct state new drop
Configure Fail2ban with a Tight SSH Jail
Fail2ban reads auth logs and bans IPs after a configurable number of failures. The default SSH jail in Fail2ban 1.x ships with maxretry=5 and bantime=10m, which is too permissive for production systems. We set maxretry=3 and bantime=1h minimum, with a findtime of 10 minutes.
Create a local override file rather than editing the defaults, so package updates do not overwrite your configuration:
Check that the log path matches your system. Ubuntu uses /var/log/auth.log, Rocky Linux uses /var/log/secure. On systemd-only systems without a syslog daemon, set backend = systemd and remove the logpath line.
Verify the jail is active and check current ban status:
For persistent bans on known hostile ranges, maintain an ipset or nftables set populated from threat intelligence feeds. Fail2ban handles reactive banning; proactive blocklisting of known scanner networks reduces log noise significantly.
# /etc/fail2ban/jail.d/sshd-hardened.conf
[sshd]
enabled = true
port = ssh
logpath = /var/log/auth.log
maxretry = 3
findtime = 10m
bantime = 1h
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8
# Reload and check
systemctl reload fail2ban
fail2ban-client status sshd
fail2ban-client get sshd banip
Add TOTP Two-Factor Authentication
Key-based authentication is strong, but adding TOTP as a second factor means a stolen private key is not enough on its own. This is particularly relevant for jump hosts and bastion servers where the blast radius of a compromised key is high.
Install libpam-google-authenticator and configure it per user:
Then configure PAM and sshd to require both the key and the TOTP code. The sshd_config changes are critical: AuthenticationMethods publickey,keyboard-interactive forces both factors in sequence.
Note that KbdInteractiveAuthentication must be yes for TOTP to work, even though we set it to no in the password authentication section. The PAM module is configured to skip the TOTP prompt for password auth (nullok on the pam_google_authenticator line prevents lockout if a user has not enrolled yet).
For service accounts running automated jobs, exclude them from TOTP using a Match block:
Before rolling this out broadly, test the full auth flow in a screen or tmux session so a failed TOTP configuration does not strand you outside the server.
# Install
apt install libpam-google-authenticator # Debian/Ubuntu
dnf install google-authenticator-libpam # Rocky/RHEL
# Per user setup (run as the user, not root)
google-authenticator -t -d -f -r 3 -R 30 -w 3
# /etc/pam.d/sshd - add before @include common-auth
auth required pam_google_authenticator.so nullok
# /etc/ssh/sshd_config.d/99-hardening.conf additions
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive
# Exclude service accounts from 2FA
Match User deploy,ci-runner
AuthenticationMethods publickey
systemctl reload sshd
Tune sshd_config Session and Timeout Settings
Idle sessions left open are a risk, particularly on shared jump hosts. ClientAliveInterval and ClientAliveCountMax control how long an idle session stays open. With ClientAliveInterval 300 and ClientAliveCountMax 2, a session times out after 10 minutes of no response.
LoginGraceTime controls how long an unauthenticated connection is held open. The default is 120 seconds, which is enough time for a slow brute-force attempt to hold a connection slot. Set it to 30 seconds.
MaxAuthTries limits authentication attempts per connection. Set it to 3. MaxSessions and MaxStartups control concurrent sessions and unauthenticated connections respectively.
TCPKeepAlive operates at the TCP level and is less reliable than the SSH-level keepalive. Disable it and rely on ClientAliveInterval instead.
X11Forwarding and AllowAgentForwarding should be off globally unless you have a specific use case. Agent forwarding is particularly dangerous on shared hosts because any user with root access on that host can hijack forwarded agents.
# /etc/ssh/sshd_config.d/99-hardening.conf
LoginGraceTime 30
MaxAuthTries 3
MaxSessions 5
MaxStartups 10:30:60
ClientAliveInterval 300
ClientAliveCountMax 2
TCPKeepAlive no
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
GatewayPorts no
PermitUserEnvironment no
Banner /etc/ssh/banner.txt
# Create a legal warning banner
echo "Authorized access only. All sessions are logged." > /etc/ssh/banner.txt
Change the Default Port and Use Port Knocking Selectively
Moving SSH off port 22 is not a security measure in itself - any port scan will find it - but it eliminates essentially all automated noise in auth logs, which makes real attack attempts easier to spot. We tested this on a public VPS: switching from port 22 to port 2222 reduced auth log entries from ~4,000 per day to under 20.
Change the port in sshd_config and update your firewall before reloading:
Port knocking adds a layer on top: the SSH port is firewalled by default and only opens after a specific sequence of connection attempts to other ports. knockd is the standard tool. The sequence is defined in /etc/knockd.conf and the daemon watches the firewall log.
This is operationally complex - if knockd fails or the sequence is forgotten, you need console access. For most teams, restricting SSH to a VPN-assigned IP range achieves the same isolation with less operational risk than port knocking. Port knocking is worth considering for single-admin systems or in environments where a static IP range is not possible.
For DevOps teams automating SSH port changes across fleets, tools like those aggregated at taskbotshub.ai can help wrap these configuration steps into reusable automation pipelines - particularly useful when you need to push consistent sshd_config changes across dozens of nodes without configuration drift.
# In sshd_config
Port 2222
# Update firewall (ufw example)
ufw allow 2222/tcp
ufw delete allow 22/tcp
# nftables example
nft add rule inet filter input tcp dport 2222 ct state new,established accept
nft delete rule inet filter input handle
# Update SELinux port context on RHEL-based systems
semanage port -a -t ssh_port_t -p tcp 2222
# Test before reloading - from another terminal:
ssh -p 2222 user@server-ip
# If successful
systemctl reload sshd
Set Up SSH Certificate Authority for Team Access
Managing individual authorized_keys files across a fleet does not scale and creates security debt - former employees' keys often persist longer than their access should. SSH certificates solve this by letting you sign user keys with a CA key. You revoke access by removing the CA's trust or using a certificate revocation list, not by hunting down individual authorized_keys entries.
Create a CA key pair (keep the private key offline or in a secrets manager):
Sign a user key with an expiry. The -n flag sets the valid principals (matching usernames on the target hosts). The -V flag sets validity. A 24-hour validity is aggressive but appropriate for automated CI pipelines; 30 days is reasonable for human users.
On each server, add TrustedUserCAKeys to sshd_config instead of maintaining authorized_keys:
To invalidate a specific certificate before expiry, add its serial number to a KRL (Key Revocation List) and set RevokedKeys in sshd_config. Generate the KRL with ssh-keygen -k.
For naming and tracking CA keys in multi-team environments, use a consistent naming convention from the start. Services like nicename.me are useful when you are also thinking about external-facing project or service naming conventions that need to stay consistent across infrastructure and domain registration.
# Generate CA key (do this once, protect the private key)
ssh-keygen -t ed25519 -f /etc/ssh/ca_key -C "ssh-ca-$(hostname)-$(date +%Y%m%d)"
# Sign a user's public key
ssh-keygen -s /etc/ssh/ca_key \
-I "alice-workstation-$(date +%Y%m%d)" \
-n alice,deploy \
-V +30d \
-z 42 \
~/.ssh/id_ed25519.pub
# This creates id_ed25519-cert.pub
# On each SSH server - add to sshd_config
TrustedUserCAKeys /etc/ssh/ca_key.pub
# Remove AuthorizedKeysFile if migrating fully to cert auth
# AuthorizedKeysFile /dev/null
# Create KRL and configure revocation
ssh-keygen -k -f /etc/ssh/revoked_keys
# RevokedKeys /etc/ssh/revoked_keys
# Add to KRL by serial number
ssh-keygen -k -f /etc/ssh/revoked_keys -u -z 42 /etc/ssh/ca_key.pub
Automate Auditing and Compliance Checking
One-time hardening decays. Package updates can restore default config files, new team members can add permissive authorized_keys entries, and cron jobs can accumulate without review. Automated auditing catches this drift before it becomes a breach.
For ongoing ssh-audit checks, add a weekly cron job that pipes results to a monitoring system or sends a diff alert:
For CIS Benchmark compliance, OpenSCAP with the SSH profile provides a structured check. On RHEL/Rocky:
For authorized_keys drift, a simple script that hashes all authorized_keys files on a schedule and alerts on changes is effective. Store the baseline hash in a read-only location.
Systemd's SSH socket activation (introduced in OpenSSH 9.4 with -D flag support and better in 9.6+) can be combined with systemd's resource controls to limit the sshd process to specific CPU and memory budgets, preventing a compromised SSH daemon from using the server as a pivot point for resource-intensive activity.
Log all SSH sessions to a remote syslog server that the server itself cannot write to. On Ubuntu, configure rsyslog to forward auth.log:
If your team runs automated config management - Ansible, Puppet, or Salt - encode the full sshd hardening configuration as a role and run it on every provision and weekly thereafter. The configuration in this guide maps directly to an Ansible template with no changes needed.
# Weekly ssh-audit cron job
cat > /etc/cron.weekly/ssh-audit-check << 'EOF'
#!/bin/bash
RESULT=$(ssh-audit --json localhost 2>/dev/null)
FAILS=$(echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len([x for x in d.get('recommendations',{}).get('critical',[]) if x]))" 2>/dev/null)
if [[ "$FAILS" -gt 0 ]]; then
echo "SSH audit failures: $FAILS" | mail -s "SSH Audit FAIL: $(hostname)" ops@example.com
fi
EOF
chmod +x /etc/cron.weekly/ssh-audit-check
# OpenSCAP SSH check (RHEL/Rocky)
scap-security-guide --profile xccdf_org.ssgproject.content_profile_cis_server_l1
# Remote syslog forwarding in rsyslog.conf
echo 'auth,authpriv.* @syslog.internal.example.com:514' >> /etc/rsyslog.d/50-remote.conf
systemctl restart rsyslog
# Authorized keys baseline hash
find /home -name authorized_keys -exec sha256sum {} \; > /var/lib/ssh-audit/authorized_keys.baseline
# Run weekly and diff against baseline