Harden sshd_config with modern cipher suites, configure fail2ban with progressive banning, set up Google Authenticator for two-factor authentication, deploy auditd rules for login forensics, and automate compliance checks. This guide takes a default VPS from dangerously exposed to production-hardened in under an hour.
A default SSH configuration is an open invitation. According to a 2024 Rapid7 study, a new VPS receives its first SSH brute-force attempt within 4 minutes of being provisioned. The average server faces 13,000 SSH login attempts per day. Most attackers use automated credential-stuffing tools that cycle through common username/password combinations. The SANS Institute found that 80% of successful breaches start with a compromised SSH credential. This guide implements defense-in-depth: harden the SSH daemon, rate-limit attackers, add a second authentication factor, and log everything for forensic analysis.
---
1. Harden sshd_config
The SSH daemon configuration file controls every aspect of SSH security. The default configuration prioritizes compatibility over security. These changes enforce modern security practices while maintaining operational usability.
Create a hardened sshd_config
<h1>Backup the original configuration</h1>
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup.$(date +%Y%m%d)
<h1>Edit the SSH configuration</h1>
cat > /etc/ssh/sshd_config.d/hardening.conf << 'EOF'
<h1>============================================</h1>
<h1>SSH Hardening Configuration</h1>
<h1>============================================</h1>
<h1>Protocol and authentication</h1>
Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
MaxAuthTries 3
MaxSessions 3
LoginGraceTime 30
<h1>Disable unnecessary features</h1>
PermitEmptyPasswords no
X11Forwarding no
AllowTcpForwarding no
AllowAgentForwarding no
PermitUserEnvironment no
PermitTunnel no
GatewayPorts no
<h1>Restrict to specific users/groups</h1>
AllowGroups ssh-users
<h1>Cipher and MAC configuration (modern, secure)</h1>
KexAlgorithms [email protected],curve25519-sha256,[email protected]
Ciphers [email protected],[email protected],[email protected]
MACs [email protected],[email protected]
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
<h1>Logging</h1>
LogLevel VERBOSE
SyslogFacility AUTH
<h1>Connection management</h1>
ClientAliveInterval 300
ClientAliveCountMax 2
TCPKeepAlive no
<h1>Banner</h1>
Banner /etc/ssh/banner
EOF
Create the ssh-users group and add your admin user
<h1>Create the SSH access group</h1>
groupadd ssh-users
<h1>Add your admin user to the group</h1>
usermod -aG ssh-users your-admin-user
<h1>Create a login banner</h1>
cat > /etc/ssh/banner << 'EOF'
========================================
Authorized access only.
All sessions are logged and monitored.
========================================
EOF
<h1>Validate the configuration</h1>
sshd -t
<h1>Reload SSH (do NOT restart - keep your current session alive)</h1>
systemctl reload sshd
Critical: Test SSH access in a new terminal before closing your current session. If the configuration has an error, you will be locked out. Always keep one active session as a safety net.
SSH key setup (if not already done)
<h1>Generate an Ed25519 key (on your local machine)</h1>
ssh-keygen -t ed25519 -C "[email protected]" -f ~/.ssh/vps_admin
<h1>Copy public key to server</h1>
ssh-copy-id -i ~/.ssh/vps_admin.pub user@your-server
<h1>Set proper permissions on the server</h1>
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
Security setting explanations
| Setting | Value | Why |
| PermitRootLogin | no | Forces attackers to guess username AND password |
| PasswordAuthentication | no | Eliminates brute-force attacks entirely |
| MaxAuthTries | 3 | Disconnects after 3 failed attempts |
| KexAlgorithms | Modern only | Removes algorithms vulnerable to quantum attacks |
| Ciphers | AEAD only | ChaCha20/AES-GCM resist known attacks |
| LogLevel | VERBOSE | Logs public key fingerprints for forensics |
| ClientAliveInterval | 300 | Disconnects idle sessions after 10 minutes |
| AllowGroups | ssh-users | Whitelist approach instead of blacklist |
---
2. Configure fail2ban with Progressive Banning
Even with key-only authentication, SSH brute-force attempts consume server resources and fill logs. fail2ban monitors log files and temporarily bans IPs that show malicious patterns. Progressive banning increases ban duration for repeat offenders.
Install and configure fail2ban
<h1>Install fail2ban</h1>
apt-get install -y fail2ban
<h1>Create local configuration (never edit jail.conf directly)</h1>
cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 3
banaction = iptables-multiport
backend = systemd
ignoreip = 127.0.0.1/8 ::1
[sshd]
enabled = true
port = ssh
filter = sshd
maxretry = 3
bantime = 1h
[sshd-aggressive]
enabled = true
port = ssh
filter = sshd[mode=aggressive]
maxretry = 1
bantime = 24h
findtime = 24h
[recidive]
enabled = true
filter = recidive
banaction = iptables-allports
bantime = 1w
findtime = 1d
maxretry = 3
EOF
Progressive banning strategy
| Offense | Jail | Ban Duration | Trigger |
| First offense | sshd | 1 hour | 3 failed attempts in 10 minutes |
| Aggressive pattern | sshd-aggressive | 24 hours | 1 attempt with invalid user |
| Repeat offender | recidive | 1 week | Banned 3 times in 24 hours |
<h1>Start fail2ban</h1>
systemctl enable fail2ban
systemctl start fail2ban
<h1>Check status</h1>
fail2ban-client status sshd
<h1>View banned IPs</h1>
fail2ban-client status sshd | grep "Banned IP"
<h1>Unban a specific IP (if you lock yourself out)</h1>
fail2ban-client set sshd unbanip 1.2.3.4
<h1>View ban statistics</h1>
zgrep "Ban " /var/log/fail2ban.log*
awk '{print $NF}' sort uniq -c sort -rn
head -20
For VPS servers managed through Arxentra's infrastructure platform, fail2ban is pre-configured with these progressive banning rules.
---
3. Set Up Google Authenticator for MFA
Two-factor authentication adds a time-based one-time password (TOTP) requirement on top of SSH keys. Even if an attacker steals your private key, they cannot log in without the TOTP code. According to Microsoft's 2024 security report, MFA blocks 99.9% of automated attacks and 76% of targeted attacks.
Install and configure Google Authenticator PAM module
<h1>Install the PAM module</h1>
apt-get install -y libpam-google-authenticator
<h1>Run setup as the user who will use MFA</h1>
su - your-admin-user -c "google-authenticator \
--time-based \
--disallow-reuse \
--rate-limit=3 \
--rate-time=30 \
--window-size=3 \
--force"
This outputs a QR code and emergency scratch codes. Save the scratch codes securely (password manager or printed and stored in a safe). If you lose your phone, these are your only way in.
Configure PAM for SSH
<h1>Edit PAM SSH configuration</h1>
cat >> /etc/pam.d/sshd << 'EOF'
<h1>Google Authenticator MFA</h1>
auth required pam_google_authenticator.so nullok
EOF
<h1>Update sshd_config to require both key + TOTP</h1>
cat >> /etc/ssh/sshd_config.d/hardening.conf << 'EOF'
<h1>MFA: Require both public key and TOTP</h1>
AuthenticationMethods publickey,keyboard-interactive
ChallengeResponseAuthentication yes
EOF
<h1>Reload SSH</h1>
sshd -t && systemctl reload sshd
MFA login flow
MFA configuration options
| Option | Setting | Purpose |
| --time-based | TOTP (not HOTP) | Codes expire every 30 seconds |
| --disallow-reuse | Prevents replay | Same code cannot be used twice |
| --rate-limit=3 | Brute-force protection | 3 attempts per 30 seconds |
| --window-size=3 | Clock skew tolerance | Accepts codes from +/- 1 interval |
| nullok in PAM | Gradual rollout | Users without MFA can still log in |
Remove `nullok` from the PAM configuration once all users have enrolled in MFA. This makes MFA mandatory.
---
4. Deploy auditd Rules for Login Forensics
auditd is the Linux kernel audit framework. It records security-relevant events at the kernel level, making it tamper-resistant (unlike application logs that an attacker can delete). Every production server should run auditd for forensic readiness.
Install and configure auditd
<h1>Install auditd</h1>
apt-get install -y auditd audispd-plugins
<h1>Create SSH-specific audit rules</h1>
cat > /etc/audit/rules.d/ssh-hardening.rules << 'EOF'
<h1>Monitor SSH configuration changes</h1>
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /etc/ssh/sshd_config.d/ -p wa -k sshd_config
-w /etc/ssh/ssh_config -p wa -k ssh_config
<h1>Monitor authentication files</h1>
-w /etc/passwd -p wa -k passwd_changes
-w /etc/shadow -p wa -k shadow_changes
-w /etc/group -p wa -k group_changes
-w /etc/gshadow -p wa -k gshadow_changes
<h1>Monitor SSH key files</h1>
-w /root/.ssh/ -p wa -k root_ssh
-w /home/ -p wa -k home_ssh
<h1>Monitor PAM configuration</h1>
-w /etc/pam.d/ -p wa -k pam_config
<h1>Monitor sudo usage</h1>
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
<h1>Monitor failed login attempts</h1>
-w /var/log/faillog -p wa -k faillog
-w /var/log/lastlog -p wa -k lastlog
<h1>Monitor user/group management commands</h1>
-w /usr/sbin/useradd -p x -k user_management
-w /usr/sbin/userdel -p x -k user_management
-w /usr/sbin/usermod -p x -k user_management
-w /usr/sbin/groupadd -p x -k group_management
-w /usr/sbin/groupdel -p x -k group_management
<h1>Monitor privilege escalation</h1>
-a always,exit -F arch=b64 -S setuid -S setgid -k privilege_escalation
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -k root_commands
EOF
<h1>Restart auditd to apply rules</h1>
systemctl restart auditd
<h1>Verify rules are loaded</h1>
auditctl -l
Useful auditd queries
<h1>Search for SSH configuration changes</h1>
ausearch -k sshd_config --start today
<h1>Search for failed login attempts</h1>
ausearch -m USER_LOGIN --success no --start today
<h1>Search for sudo usage</h1>
ausearch -k sudoers --start today
<h1>Search for privilege escalation</h1>
ausearch -k privilege_escalation --start today
<h1>Generate a summary report</h1>
aureport --summary
<h1>Generate a login report</h1>
aureport --login --start today
<h1>Generate a failed authentication report</h1>
aureport --auth --failed --start today
auditd retention configuration
<h1>/etc/audit/auditd.conf</h1>
log_file = /var/log/audit/audit.log
log_format = ENRICHED
max_log_file = 50
max_log_file_action = ROTATE
num_logs = 10
space_left = 100
space_left_action = EMAIL
admin_space_left = 50
admin_space_left_action = HALT
---
5. Automated Security Compliance Checks
Hardening is not a one-time task. Configuration drift, package updates, and new user accounts can weaken your security posture over time. Run automated compliance checks weekly to catch drift early.
Security compliance check script
#!/bin/bash
<h1>ssh-compliance-check.sh</h1>
<h1>Run weekly via cron to detect configuration drift</h1>
set -euo pipefail
PASS=0
FAIL=0
WARN=0
check() {
local desc="$1"
local result="$2"
if [ "$result" = "PASS" ]; then
echo " [PASS] $desc"
PASS=$((PASS + 1))
elif [ "$result" = "WARN" ]; then
echo " [WARN] $desc"
WARN=$((WARN + 1))
else
echo " [FAIL] $desc"
FAIL=$((FAIL + 1))
fi
}
echo "=== SSH Security Compliance Check ==="
echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
<h1>Check 1: Root login disabled</h1>
if sshd -T 2>/dev/null | grep -q "permitrootlogin no"; then
check "Root login disabled" "PASS"
else
check "Root login disabled" "FAIL"
fi
<h1>Check 2: Password auth disabled</h1>
if sshd -T 2>/dev/null | grep -q "passwordauthentication no"; then
check "Password auth disabled" "PASS"
else
check "Password auth disabled" "FAIL"
fi
<h1>Check 3: fail2ban running</h1>
if systemctl is-active --quiet fail2ban; then
check "fail2ban active" "PASS"
else
check "fail2ban active" "FAIL"
fi
<h1>Check 4: auditd running</h1>
if systemctl is-active --quiet auditd; then
check "auditd active" "PASS"
else
check "auditd active" "FAIL"
fi
<h1>Check 5: No authorized_keys with weak key types</h1>
WEAK_KEYS=$(find /home -name authorized_keys -exec grep -l "ssh-dss\
ssh-rsa " {} \; 2>/dev/null
wc -l)
if [ "$WEAK_KEYS" -eq 0 ]; then
check "No weak SSH key types" "PASS"
else
check "No weak SSH key types ($WEAK_KEYS files with weak keys)" "WARN"
fi
<h1>Check 6: SSH listening on expected port only</h1>
SSH_PORTS=$(ss -tlnp
grep sshd awk '{print $4}' grep -oP '\d+$'
sort -u)
if [ "$SSH_PORTS" = "22" ]; then
check "SSH on port 22 only" "PASS"
else
check "SSH on expected port (found: $SSH_PORTS)" "WARN"
fi
<h1>Check 7: Unattended upgrades enabled</h1>
if dpkg -l unattended-upgrades 2>/dev/null | grep -q "^ii"; then
check "Unattended security upgrades" "PASS"
else
check "Unattended security upgrades" "FAIL"
fi
echo ""
echo "Results: $PASS passed, $FAIL failed, $WARN warnings"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
<h1>Add to crontab for weekly checks</h1>
echo "0 6 * * 1 /usr/local/bin/ssh-compliance-check.sh
mail -s 'SSH Compliance Report' [email protected]"
crontab -
---
6. Additional Hardening Measures
UFW firewall rules
<h1>Allow SSH from specific IP ranges only</h1>
ufw default deny incoming
ufw default allow outgoing
ufw allow from 203.0.113.0/24 to any port 22 proto tcp comment "Office SSH"
ufw allow from 198.51.100.0/24 to any port 22 proto tcp comment "VPN SSH"
ufw enable
Automatic security updates
<h1>Install and configure unattended-upgrades</h1>
apt-get install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
<h1>Verify it is enabled</h1>
cat /etc/apt/apt.conf.d/20auto-upgrades
SSH security checklist
| Control | Status | Impact |
| Key-only authentication | Must have | Eliminates brute-force |
| fail2ban | Must have | Blocks repeat offenders |
| Non-root login only | Must have | Reduces attack surface |
| MFA (Google Authenticator) | Should have | Blocks stolen key attacks |
| auditd logging | Should have | Forensic readiness |
| IP-restricted SSH | Should have | Limits attack surface |
| Automatic security updates | Should have | Patches known CVEs |
| Custom SSH port | Nice to have | Reduces log noise (not security) |
For managed servers on Arxentra's platform, SSH hardening is applied automatically during provisioning. Read our rollback strategy guide for safe deployment practices, and see the Kubernetes security baseline for container-level security.
---
Frequently Asked Questions
Should I change the default SSH port?
Changing from port 22 to a non-standard port is not a security measure but a noise-reduction technique. It reduces automated scan traffic by 95%+ in logs, making real attacks easier to spot. However, security through obscurity is not a substitute for the hardening measures in this guide. If you change the port, use a port above 1024 and below 65535, and make sure your team documents it.
How do I recover if I lock myself out?
Prevention: always keep one active SSH session while testing changes. Recovery options depend on your hosting provider: (1) VPS console access through the provider's web panel, (2) boot into single-user mode, (3) mount the disk on another server and fix the configuration. The Arxentra blog covers disaster recovery patterns for infrastructure access.
Is fail2ban enough to stop brute-force attacks?
fail2ban combined with key-only authentication is highly effective. With password authentication disabled, brute-force attacks cannot succeed regardless of fail2ban. fail2ban's value with key-only auth is reducing log noise, saving bandwidth, and blocking reconnaissance. For defense-in-depth, combine with UFW/iptables IP restrictions.
Do I need MFA if I already use SSH keys?
SSH keys alone are strong authentication, but they can be compromised through laptop theft, malware, or insider threats. MFA adds a second factor that changes every 30 seconds, making key theft alone insufficient for access. For compliance (SOC 2, PCI-DSS, HIPAA), MFA on SSH is often required.
How much CPU overhead does auditd add?
Minimal. auditd operates at the kernel level and typically adds less than 1% CPU overhead. The storage impact is more significant: expect 10-50MB of audit logs per day on a moderately active server. Configure log rotation to manage disk usage. The forensic value far outweighs the resource cost.
Should I disable SSH entirely and use a bastion host?
For environments with more than 10 servers, yes. A bastion (jump) host provides a single, hardened entry point. All SSH access goes through the bastion, and internal servers only accept SSH from the bastion's IP. This simplifies security monitoring, reduces the attack surface, and enables centralized logging. Combined with the hardening from this guide on the bastion itself, this is the most secure approach.