Introduction: SSH Is Still Your Front Door — And Attackers Know It
If you manage Linux servers, SSH is probably the tool you use more than anything else. Deploying code, troubleshooting production at 2 AM, managing infrastructure — it all flows through SSH. But here's the thing: that ubiquity makes it a massive target. According to AhnLab's Q4 2025 honeypot data, brute-force attacks account for a staggering 89% of all endpoint behaviors targeting Linux systems, with SSH as the primary entry point. The P2PInfect worm alone was responsible for over 80% of SSH-based attacks that quarter.
And the threats aren't just brute-force scripts anymore.
In 2024, the regreSSHion vulnerability (CVE-2024-6387) exposed over 14 million Internet-facing OpenSSH servers to unauthenticated remote code execution. Then in February 2025, two more critical vulnerabilities dropped — CVE-2025-26465, which enabled machine-in-the-middle attacks when VerifyHostKeyDNS was enabled, and CVE-2025-26466, allowing pre-authentication denial-of-service attacks. Meanwhile, OpenSSH 10.0 shipped in April 2025 with post-quantum cryptography as the default key exchange, which honestly signals a pretty fundamental shift in how SSH sessions are protected going forward.
So, this guide is about practical, defense-in-depth SSH hardening for modern Linux systems. We'll cover how to configure a properly hardened sshd_config, set up certificate-based authentication, deploy FIDO2 hardware keys, architect bastion hosts with ProxyJump, implement post-quantum key exchange, and monitor SSH access with real-time alerting. Everything here is production-tested and current as of early 2026.
Understanding the Modern SSH Threat Landscape
Brute-Force and Credential Stuffing at Scale
The Outlaw group showed just how aggressive modern SSH attacks have become. After going quiet from December 2024 through February 2025, they launched a massive campaign in March 2025 — compromising Linux servers worldwide through SSH brute-force to deploy cryptojacking malware. Their playbook was simple but devastatingly effective: scan for systems with port 22 open, throw dictionary attacks with common credentials, and deploy coin miners and DDoS bots on successful compromises.
This isn't a theoretical concern. Linux powers roughly 90% of public cloud workloads, and every single one of those instances has SSH enabled by default. If your SSH setup still relies on password authentication with weak credentials, you're basically running with the front door wide open.
Recent Critical Vulnerabilities
Three vulnerabilities from the past two years illustrate why keeping OpenSSH updated is absolutely non-negotiable:
- RegreSSHion (CVE-2024-6387) — A race condition in OpenSSH's signal handling that allowed unauthenticated remote code execution. It was actually a regression of an 18-year-old bug (CVE-2006-5051), proving that even well-audited software can quietly re-introduce old flaws. OpenSSH 9.8 and later include rate-limiting protections against this attack.
- CVE-2025-26465 — An active machine-in-the-middle attack on the OpenSSH client when the
VerifyHostKeyDNSoption is enabled. Affects OpenSSH client versions 6.8p1 through 9.9p1. - CVE-2025-26466 — A pre-authentication denial-of-service vulnerability affecting both the OpenSSH client and server (versions 9.5p1 through 9.9p1). Fixed in version 9.9p2.
- Terrapin Attack (CVE-2023-48795) — Allowed attackers to manipulate TCP sessions and force selection of weaker cryptographic algorithms, compromising session integrity.
The takeaway? Patch aggressively, and don't assume your SSH version is safe just because it was secure when you installed it.
Building a Hardened sshd_config from Scratch
The default OpenSSH server configuration on most distros prioritizes compatibility over security. That's understandable — it needs to work out of the box for everyone — but it means the defaults are almost never good enough for production. Here's a hardened configuration that addresses modern threats while keeping things operationally usable. I'll walk through each section and explain the reasoning.
The Complete Hardened Configuration
# /etc/ssh/sshd_config — Hardened Configuration for OpenSSH 9.9+
# Last updated: 2026-02
# ============================================================
# Network and Protocol Settings
# ============================================================
Port 22
AddressFamily inet
ListenAddress 0.0.0.0
# Use 'inet6' or 'any' if you need IPv6 support
Protocol 2
# ============================================================
# Host Keys — Prefer Ed25519, fallback to RSA-4096
# ============================================================
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
# Require minimum 3072-bit RSA keys (OpenSSH 9.1+)
RequiredRSASize 3072
# ============================================================
# Cryptographic Algorithms — Post-Quantum Ready
# ============================================================
# Key Exchange — includes post-quantum hybrid algorithm
KexAlgorithms mlkem768x25519-sha256,[email protected],curve25519-sha256,[email protected],diffie-hellman-group18-sha512,diffie-hellman-group16-sha512
# Ciphers — AEAD ciphers only
Ciphers [email protected],[email protected],[email protected]
# MACs — Encrypt-then-MAC variants only
MACs [email protected],[email protected]
# Host key algorithms
HostKeyAlgorithms ssh-ed25519,[email protected],rsa-sha2-512,[email protected],rsa-sha2-256,[email protected]
# ============================================================
# Authentication — Defense in Depth
# ============================================================
# Disable password authentication entirely
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
# Enable public key authentication
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
# Disable root login
PermitRootLogin no
# Disable empty passwords
PermitEmptyPasswords no
# Maximum authentication attempts per connection
MaxAuthTries 3
# Authentication timeout (seconds)
LoginGraceTime 20
# Disable host-based authentication
HostbasedAuthentication no
IgnoreRhosts yes
# Disable VerifyHostKeyDNS to mitigate CVE-2025-26465
VerifyHostKeyDNS no
# ============================================================
# Session Security
# ============================================================
# Limit concurrent unauthenticated connections
MaxStartups 10:30:60
# Maximum sessions per connection
MaxSessions 3
# Client keepalive — disconnect idle sessions
ClientAliveInterval 300
ClientAliveCountMax 2
# Disable TCP forwarding by default (enable per-user if needed)
AllowTcpForwarding no
AllowStreamLocalForwarding no
# Disable X11 forwarding
X11Forwarding no
# Disable agent forwarding (use ProxyJump instead)
AllowAgentForwarding no
# Disable tunnel devices
PermitTunnel no
# Refuse connections immediately on auth failure (OpenSSH 9.9+)
# RefuseConnection yes
# ============================================================
# Logging and Auditing
# ============================================================
SyslogFacility AUTH
LogLevel VERBOSE
# ============================================================
# Environment and Subsystems
# ============================================================
# Restrict environment variables
PermitUserEnvironment no
# Use internal SFTP server for chroot support
Subsystem sftp internal-sftp
# ============================================================
# Access Control — Whitelist approach
# ============================================================
# Restrict SSH access to specific users/groups
# AllowUsers deploy admin
AllowGroups ssh-users
# Deny specific users explicitly
DenyUsers root
DenyGroups root
# ============================================================
# Banner
# ============================================================
Banner /etc/ssh/banner
Key Configuration Decisions Explained
Post-quantum key exchange: The mlkem768x25519-sha256 algorithm (made the default in OpenSSH 10.0) combines the NIST-standardized ML-KEM post-quantum key encapsulation mechanism with the proven X25519 elliptic curve method. This hybrid approach protects sessions against "harvest now, decrypt later" attacks — where adversaries capture encrypted traffic today, planning to decrypt it once quantum computers become viable. If your systems run OpenSSH 9.9+, there's honestly no good reason not to enable it.
AEAD ciphers only: By restricting to chacha20-poly1305, aes256-gcm, and aes128-gcm, we ensure all ciphers provide authenticated encryption with associated data. This eliminates the need for separate MAC computation and mitigates attacks like Terrapin that exploit interactions between cipher and MAC algorithms.
Encrypt-then-MAC: The -etm MAC variants calculate the MAC after encryption rather than before, which is cryptographically safer and resistant to certain padding oracle attacks. It's a small change that matters more than you'd think.
RequiredRSASize 3072: NIST recommends a minimum of 3072-bit RSA keys, which provide roughly 128 bits of security. This directive flat-out rejects any RSA keys below this threshold for both user and host-based authentication.
MaxStartups 10:30:60: This rate-limits unauthenticated connections. After 10 unauthenticated connections, it starts dropping new ones with 30% probability, increasing linearly to 100% at 60 connections. It's a direct mitigation for the regreSSHion vulnerability and brute-force attacks in general.
Applying and Validating the Configuration
Always — and I mean always — test your configuration before restarting the SSH daemon. A syntax error can lock you out of a remote system permanently (I've seen it happen more than once):
# Validate the configuration file
sudo sshd -t
# If validation passes, restart the service
sudo systemctl restart sshd
# Verify the daemon is running with the new config
sudo systemctl status sshd
# Test from another terminal BEFORE closing your current session
ssh -v user@your-server
Critical safety tip: keep an existing SSH session open while testing configuration changes. If the new config breaks something, you've still got your existing session to fix it. Trust me on this one.
SSH Certificate-Based Authentication
Traditional SSH public key authentication has a significant operational problem at scale: managing authorized_keys files across hundreds or thousands of servers becomes an absolute nightmare. Every time someone joins or leaves your team, you need to add or remove their key from every server they had access to. SSH certificates solve this problem elegantly.
How SSH Certificates Work
SSH certificates are essentially signed SSH public keys that include metadata — the authorized username, a validity period, access restrictions, and so on. Instead of distributing individual public keys to every server, you set up a Certificate Authority (CA) that signs user keys. Servers trust the CA, and any certificate signed by that CA is automatically accepted. No more authorized_keys file management.
The security advantages are substantial:
- Short-lived credentials — Certificates can expire after hours or days, not years. If a key gets compromised, it essentially self-revokes.
- Centralized identity — One CA controls all access. Revoking someone's access is a single operation, not a server-by-server slog.
- Auditable access — Certificate metadata (principal, serial number, source-address restrictions) provides clear forensic trails.
- Reduced key sprawl — No more stale authorized_keys entries from people who left the company two years ago.
Setting Up an SSH Certificate Authority
Here's how to set up a complete SSH CA infrastructure. One important note: use separate CA keys for user and host certificates. This is a critical security boundary you don't want to blur.
# Create a dedicated directory for CA keys
sudo mkdir -p /etc/ssh/ca
sudo chmod 700 /etc/ssh/ca
# Generate the User CA key (Ed25519)
sudo ssh-keygen -t ed25519 -f /etc/ssh/ca/user_ca -C "User CA Key"
# Generate the Host CA key (Ed25519)
sudo ssh-keygen -t ed25519 -f /etc/ssh/ca/host_ca -C "Host CA Key"
# Set restrictive permissions
sudo chmod 600 /etc/ssh/ca/user_ca /etc/ssh/ca/host_ca
sudo chmod 644 /etc/ssh/ca/user_ca.pub /etc/ssh/ca/host_ca.pub
Signing Host Certificates
Host certificates let clients verify they're connecting to a legitimate server without relying on TOFU (trust on first use) or manual fingerprint verification. If you've ever just typed "yes" at that fingerprint prompt without actually checking it (we've all done it), this is the fix:
# Sign the server's host key with the Host CA
sudo ssh-keygen -s /etc/ssh/ca/host_ca \
-I "webserver-01.example.com" \
-h \
-n "webserver-01.example.com,192.168.1.50" \
-V +52w \
/etc/ssh/ssh_host_ed25519_key.pub
# This creates /etc/ssh/ssh_host_ed25519_key-cert.pub
Add the certificate to your sshd_config:
# In /etc/ssh/sshd_config
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
Signing User Certificates
When a team member needs access, you sign their public key with the User CA:
# Sign a user's public key with time-limited validity
sudo ssh-keygen -s /etc/ssh/ca/user_ca \
-I "[email protected]" \
-n "deploy,jane" \
-V +8h \
-O source-address=10.0.0.0/8 \
/path/to/jane_id_ed25519.pub
# Flags explained:
# -I: Certificate identity (for logging)
# -n: Authorized principals (usernames this cert can log in as)
# -V: Validity period (8 hours from now)
# -O source-address: Restrict certificate to specific source IPs
Configure the server to trust the User CA:
# In /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/ca/user_ca.pub
# Optionally specify authorized principals file
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
Create principals files for each user account on the server:
# /etc/ssh/auth_principals/deploy
deploy
sre-team
# /etc/ssh/auth_principals/jane
jane
dev-team
Client-Side Host CA Trust
To enable clients to verify host certificates and eliminate those TOFU warnings:
# Add to ~/.ssh/known_hosts or /etc/ssh/ssh_known_hosts
@cert-authority *.example.com ssh-ed25519 AAAA...host_ca_public_key...
Automating Certificate Issuance
For production environments, you'll probably want to integrate certificate signing with your identity provider. Tools like HashiCorp Vault's SSH secrets engine or Smallstep's step-ca can automate the entire lifecycle:
# Using HashiCorp Vault to sign SSH keys
vault write ssh-client-signer/sign/deploy \
public_key=@/home/jane/.ssh/id_ed25519.pub \
valid_principals="deploy" \
ttl="8h"
# Using step-ca for SSH certificate signing
step ssh certificate [email protected] id_ed25519 \
--principal deploy \
--not-after 8h
FIDO2 Hardware Key Authentication
Since OpenSSH 8.2, you can generate SSH keys that are bound to FIDO2-compatible hardware security keys like YubiKeys. This is, in my opinion, the strongest form of SSH authentication available today: the private key never leaves the hardware device, making it immune to malware, phishing, and remote credential theft.
Generating FIDO2-Backed SSH Keys
# Generate a non-resident FIDO2 key (key handle stored on disk)
ssh-keygen -t ed25519-sk -C "[email protected] - YubiKey"
# Generate a resident key (key stored entirely on the hardware device)
ssh-keygen -t ed25519-sk -O resident -O verify-required \
-C "[email protected] - YubiKey Resident"
# Flags explained:
# -t ed25519-sk: Ed25519 key backed by a security key
# -O resident: Store key on the device (portable across machines)
# -O verify-required: Require PIN + touch for every use
The -O verify-required flag is really important here — it enforces both user verification (PIN) and user presence (physical touch) on every SSH operation. Without it, only a touch is required, which means someone with physical access to your unlocked workstation could use the key without knowing your PIN.
Resident vs. Non-Resident Keys
The choice between resident and non-resident keys comes down to a security/convenience tradeoff:
- Non-resident keys store a key handle on disk in
~/.ssh/. The handle by itself is useless without the hardware device, but you need both the device and the specific machine where the key was generated. More secure, since losing the device alone doesn't expose the key handle. - Resident keys live entirely on the hardware device. You can use them from any machine by importing with
ssh-keygen -K, which makes them portable. The downside? Anyone who steals your device and knows your PIN has full access. For resident keys, always enableverify-requiredand set a strong PIN.
Recovering Resident Keys on a New Machine
# Import all resident keys from the hardware device
ssh-keygen -K
# This creates id_ed25519_sk_rk and id_ed25519_sk_rk.pub
# in the current directory
Combining FIDO2 Keys with SSH Certificates
For maximum security, you can combine FIDO2 hardware keys with SSH certificates. The user generates a FIDO2-backed key, and the CA signs it with a short-lived certificate:
# User generates FIDO2 key
ssh-keygen -t ed25519-sk -O resident -O verify-required
# CA signs the FIDO2 public key with a 12-hour certificate
ssh-keygen -s /etc/ssh/ca/user_ca \
-I "[email protected]" \
-n "deploy" \
-V +12h \
id_ed25519_sk.pub
This gives you three layers of protection: a hardware-bound private key, short-lived certificate expiration, and principal-based access control. That's about as solid as it gets.
Bastion Host Architecture with ProxyJump
A bastion host (sometimes called a jump host) is a hardened server that serves as the single entry point to your internal network. Instead of exposing every server's SSH port to the internet, you expose only the bastion. All SSH connections to internal servers must pass through it.
Why ProxyJump Over Agent Forwarding
The older approach of using SSH agent forwarding (ssh -A) to hop through bastion hosts has a fundamental security flaw: any root user on the bastion can hijack your forwarded agent socket and authenticate as you on other servers. That's a pretty scary attack vector when you think about it. ProxyJump (ssh -J, available since OpenSSH 7.3) eliminates this risk entirely — it creates a TCP tunnel through the bastion without ever forwarding your SSH agent.
Configuring ProxyJump
Set up your local SSH config for seamless multi-hop connections:
# ~/.ssh/config
# Bastion host — the only server exposed to the internet
Host bastion
HostName bastion.example.com
User jump
Port 22
IdentityFile ~/.ssh/id_ed25519_sk
# Harden the bastion connection
ForwardAgent no
ForwardX11 no
# Internal web servers — accessed via bastion
Host web-*
ProxyJump bastion
User deploy
IdentityFile ~/.ssh/id_ed25519
ForwardAgent no
# Database servers — accessed via bastion with additional restrictions
Host db-*
ProxyJump bastion
User dbadmin
IdentityFile ~/.ssh/id_ed25519_db
ForwardAgent no
# Restrict to database-related port forwarding only
LocalForward 5432 localhost:5432
With this setup, connecting to an internal server is completely transparent:
# Direct connection through bastion — no separate SSH session needed
ssh web-prod-01
# Multi-hop through multiple bastions
ssh -J bastion1.example.com,bastion2.internal db-prod-01
Hardening the Bastion Host
The bastion itself needs to be locked down more aggressively than any other server in your fleet. Here are the key additional hardening steps:
# Additional sshd_config for bastion host
# Disable all forwarding except what's needed for ProxyJump
AllowTcpForwarding yes
PermitOpen any
AllowStreamLocalForwarding no
AllowAgentForwarding no
X11Forwarding no
PermitTunnel no
# No interactive shell
ForceCommand /usr/sbin/nologin
# Or use a restricted match block for jump-only users
Match User jump
AllowTcpForwarding yes
PermitOpen any
ForceCommand /usr/sbin/nologin
X11Forwarding no
AllowAgentForwarding no
Match User admin
AllowTcpForwarding no
ForceCommand none
The ForceCommand /usr/sbin/nologin directive is critical — it prevents anyone from getting a shell on the bastion, even with valid credentials. The bastion acts purely as a network relay, nothing more.
Automated Intrusion Detection and Response
Fail2Ban Configuration for SSH
Fail2Ban monitors SSH authentication logs and automatically bans IP addresses showing malicious behavior. Here's a hardened configuration that goes beyond the defaults:
# /etc/fail2ban/jail.local
[sshd]
enabled = true
port = ssh
filter = sshd
backend = systemd
maxretry = 3
findtime = 600
bantime = 3600
banaction = nftables-multiport
# Aggressive mode — ban for 24 hours after repeated offenses
[sshd-aggressive]
enabled = true
port = ssh
filter = sshd[mode=aggressive]
backend = systemd
maxretry = 1
findtime = 86400
bantime = 86400
banaction = nftables-multiport
Real-Time SSH Log Monitoring with systemd
Here's a monitoring script that watches SSH authentication events and sends alerts. It's not fancy, but it works remarkably well in practice:
#!/bin/bash
# /usr/local/bin/ssh-monitor.sh
# Real-time SSH authentication monitoring
ALERT_EMAIL="[email protected]"
WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
journalctl -u sshd -f --no-pager | while read -r line; do
# Detect successful logins from unusual sources
if echo "$line" | grep -q "Accepted publickey"; then
user=$(echo "$line" | grep -oP 'for \K\S+')
ip=$(echo "$line" | grep -oP 'from \K[\d.]+')
# Check if IP is in the known-good list
if ! grep -q "$ip" /etc/ssh/known_ips.txt 2>/dev/null; then
msg="SSH LOGIN ALERT: User '$user' from unknown IP $ip"
logger -p auth.warning "$msg"
# Send Slack notification
curl -s -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"$msg\"}" \
"$WEBHOOK_URL" &>/dev/null
fi
fi
# Detect repeated failed authentication attempts
if echo "$line" | grep -q "Failed password\|Failed publickey"; then
ip=$(echo "$line" | grep -oP 'from \K[\d.]+')
logger -p auth.warning "SSH FAILED AUTH from $ip: $line"
fi
# Detect SSH tunneling attempts
if echo "$line" | grep -q "refused local port forward\|refused remote port forward"; then
logger -p auth.crit "SSH TUNNEL ATTEMPT: $line"
fi
done
Auditing SSH with auditd
The Linux audit framework can capture all SSH-related events for compliance and forensics. This is especially important if you're working in a regulated environment (and even if you're not, having detailed audit trails has saved me more than once during incident investigations):
# /etc/audit/rules.d/ssh.rules
# Monitor sshd configuration changes
-w /etc/ssh/sshd_config -p wa -k sshd_config_change
-w /etc/ssh/ssh_config -p wa -k ssh_config_change
-w /etc/ssh/ca/ -p wa -k ssh_ca_change
# Monitor authorized_keys modifications
-w /home/ -p wa -k authorized_keys_change -F path=*/.ssh/authorized_keys
# Monitor SSH key generation
-a always,exit -F arch=b64 -S execve -F exe=/usr/bin/ssh-keygen -k ssh_keygen
# Monitor SSH binary changes
-w /usr/sbin/sshd -p wa -k sshd_binary_change
-w /usr/bin/ssh -p wa -k ssh_binary_change
# Log all SSH connection attempts
-a always,exit -F arch=b64 -S connect -F a2=16 -F success=1 -k ssh_connection
Query audit logs for SSH-related events:
# Search for SSH configuration changes
sudo ausearch -k sshd_config_change -i
# Search for authorized_keys modifications in the last 24 hours
sudo ausearch -k authorized_keys_change -i --start today
# Generate a summary report
sudo aureport --auth --summary
Firewall Integration with nftables
Don't rely on SSH-level protections alone. Complement your hardening with a firewall configuration that restricts SSH access at the network level:
#!/usr/sbin/nft -f
# /etc/nftables.d/ssh-hardening.nft
table inet ssh_filter {
# Track SSH connection rates
set ssh_meter {
type ipv4_addr
flags dynamic,timeout
timeout 10m
}
# Whitelist for trusted management IPs
set ssh_whitelist {
type ipv4_addr
flags interval
elements = {
10.0.0.0/8,
172.16.0.0/12
}
}
chain ssh_input {
# Always allow whitelisted IPs
ip saddr @ssh_whitelist accept
# Rate limit: max 4 new SSH connections per minute per IP
tcp dport 22 ct state new \
add @ssh_meter { ip saddr limit rate 4/minute burst 6 packets } \
accept
# Drop everything else hitting SSH
tcp dport 22 drop
}
}
This gives unlimited SSH connections from your trusted management networks while rate-limiting everyone else to 4 new connections per minute — plenty for legitimate use, but effective against brute-force attacks.
Post-Quantum SSH: Preparing for the Future
OpenSSH 10.0 made mlkem768x25519-sha256 — a hybrid post-quantum key exchange algorithm — the default. OpenSSH 10.1 goes further by warning users whenever a non-post-quantum key exchange scheme is negotiated. This isn't theoretical paranoia. Intelligence agencies and well-funded adversaries are already suspected of capturing encrypted traffic for future decryption once large-scale quantum computers arrive (the so-called "harvest now, decrypt later" strategy).
Verifying Your Post-Quantum Configuration
# Check which KEX algorithms your SSH connection is using
ssh -vv user@server 2>&1 | grep "kex:"
# Expected output for post-quantum connection:
# kex: algorithm: mlkem768x25519-sha256
# Audit your server's algorithm support
ssh-audit your-server.example.com
# If you don't have ssh-audit installed:
# pip install ssh-audit
# Or use the web version at https://www.sshaudit.com
Generating Post-Quantum Safe Host Keys
While key exchange algorithms protect sessions in transit, your host keys need attention too. Ed25519 remains the recommended host key algorithm for now — post-quantum signature algorithms for SSH keys are still being standardized. The hybrid key exchange ensures that even if Ed25519 is eventually broken by quantum computers, previously captured sessions stay protected:
# Regenerate host keys with strong algorithms
sudo rm /etc/ssh/ssh_host_*_key*
sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
sudo ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ""
# Remove legacy DSA and ECDSA host keys if present
sudo rm -f /etc/ssh/ssh_host_dsa_key* /etc/ssh/ssh_host_ecdsa_key*
# Restart sshd
sudo systemctl restart sshd
Removing Weak Diffie-Hellman Moduli
The /etc/ssh/moduli file contains Diffie-Hellman group exchange parameters. Any entries smaller than 3072 bits need to go:
# Back up the original moduli file
sudo cp /etc/ssh/moduli /etc/ssh/moduli.backup
# Remove moduli smaller than 3072 bits (column 5 is the bit size)
sudo awk '$5 >= 3072' /etc/ssh/moduli > /tmp/moduli.safe
sudo mv /tmp/moduli.safe /etc/ssh/moduli
sudo chmod 644 /etc/ssh/moduli
Comprehensive Validation and Testing
After implementing all these hardening measures, you need to actually verify everything works. Here's a multi-layered testing approach.
Using ssh-audit for Automated Assessment
# Install ssh-audit
pip install ssh-audit
# Run a comprehensive audit
ssh-audit --level=warn your-server.example.com
# Key things to check in the output:
# - No weak key exchange algorithms
# - No weak ciphers (CBC mode, DES, RC4)
# - No weak MACs (MD5, SHA1 without ETM)
# - Host key size >= 3072 bits (RSA)
# - Post-quantum KEX algorithms present
Manual Testing Checklist
# Test 1: Verify password authentication is disabled
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no user@server
# Should fail with "Permission denied"
# Test 2: Verify root login is disabled
ssh root@server
# Should fail even with valid key
# Test 3: Verify weak algorithms are rejected
ssh -o KexAlgorithms=diffie-hellman-group1-sha1 user@server
# Should fail with "no matching key exchange method found"
# Test 4: Verify connection rate limiting works
for i in $(seq 1 20); do
ssh -o ConnectTimeout=2 -o BatchMode=yes user@server 2>&1 &
done
# Later connections should be refused
# Test 5: Verify FIDO2 key requires touch
ssh -i ~/.ssh/id_ed25519_sk user@server
# Should prompt for physical touch on the security key
# Test 6: Verify ProxyJump works
ssh -J bastion web-prod-01
# Should connect through bastion transparently
Putting It All Together: A Defense-in-Depth Checklist
Here's a quick-reference summary of every hardening measure we've covered, organized as an actionable checklist:
- Update OpenSSH to version 9.9p2 or later (preferably 10.0+) to get post-quantum defaults and critical security fixes
- Harden sshd_config with the complete configuration above — disable passwords, restrict algorithms, set rate limits
- Deploy SSH certificates with separate user and host CAs, short-lived certificates, and principal-based access control
- Enable FIDO2 hardware keys with
verify-requiredfor all privileged users — combine with SSH certificates for maximum security - Architect bastion hosts using ProxyJump — never expose internal servers' SSH ports directly to the internet
- Configure Fail2Ban with aggressive settings — 3 failed attempts triggers a 1-hour ban
- Set up audit rules for all SSH-related events — configuration changes, key generation, connection attempts
- Implement nftables rate limiting as an additional network-level defense layer
- Remove weak DH moduli — purge anything below 3072 bits from
/etc/ssh/moduli - Monitor and alert on SSH events in real-time — unusual source IPs, failed authentication spikes, tunneling attempts
- Scan regularly with
ssh-audit— automate it in CI/CD if you can
SSH hardening isn't a set-and-forget kind of thing. New vulnerabilities get discovered regularly, cryptographic best practices evolve, and your attack surface changes as your infrastructure grows. Schedule quarterly reviews of your SSH configuration, keep OpenSSH updated, rotate CA keys annually, and monitor authentication logs daily. The goal isn't perfect security — that doesn't exist. It's making your SSH infrastructure resilient enough that attackers move on to easier targets.