Secure SSH Hardening for VPS: Complete Server Lockdown Guide

16 min read
sshsecurityfail2banmfaauditdhardeningvpslinux

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

SettingValueWhy

PermitRootLoginnoForces attackers to guess username AND password
PasswordAuthenticationnoEliminates brute-force attacks entirely
MaxAuthTries3Disconnects after 3 failed attempts
KexAlgorithmsModern onlyRemoves algorithms vulnerable to quantum attacks
CiphersAEAD onlyChaCha20/AES-GCM resist known attacks
LogLevelVERBOSELogs public key fingerprints for forensics
ClientAliveInterval300Disconnects idle sessions after 10 minutes
AllowGroupsssh-usersWhitelist 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

OffenseJailBan DurationTrigger

First offensesshd1 hour3 failed attempts in 10 minutes
Aggressive patternsshd-aggressive24 hours1 attempt with invalid user
Repeat offenderrecidive1 weekBanned 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}'sortuniq -csort -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

  • Client presents SSH public key (automatic)
  • Server validates public key against authorized_keys
  • Server prompts for verification code
  • User enters 6-digit TOTP code from authenticator app
  • Access granted only if both factors pass
  • MFA configuration options

    OptionSettingPurpose

    --time-basedTOTP (not HOTP)Codes expire every 30 seconds
    --disallow-reusePrevents replaySame code cannot be used twice
    --rate-limit=3Brute-force protection3 attempts per 30 seconds
    --window-size=3Clock skew toleranceAccepts codes from +/- 1 interval
    nullok in PAMGradual rolloutUsers 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 sshdawk '{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

    ControlStatusImpact

    Key-only authenticationMust haveEliminates brute-force
    fail2banMust haveBlocks repeat offenders
    Non-root login onlyMust haveReduces attack surface
    MFA (Google Authenticator)Should haveBlocks stolen key attacks
    auditd loggingShould haveForensic readiness
    IP-restricted SSHShould haveLimits attack surface
    Automatic security updatesShould havePatches known CVEs
    Custom SSH portNice to haveReduces 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.