LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

SSH Hardening: Locking Down the Door Every Server Has Open

sshsecuritylinuxhardeningdevopssysadminnetworking

SSH is the front door to every Linux server. It’s also one of the most attacked services on the internet — automated scanners probe port 22 constantly, trying default credentials and known exploits. Default SSH configuration is functional but not secure. This guide covers what to lock down, what to enable, and how to build an SSH setup that’s both convenient for legitimate users and extremely hostile to everyone else.


The Threat Model

Before changing settings, it’s worth knowing what you’re defending against:

  • Automated brute force — bots trying millions of username/password combinations per hour
  • Credential stuffing — using leaked passwords from other breaches
  • Key theft — if your private key is compromised, any server that trusts it is compromised
  • Man-in-the-middle — attacker intercepts the connection before you authenticate
  • Lateral movement — attacker uses your SSH keys/agent to hop from a compromised machine to others
  • Insider/supply chain — authorized user adds unauthorized keys or abuses legitimate access

Most public-facing servers absorb thousands of failed login attempts daily. The hardening steps below eliminate virtually all of these vectors.


Step 1: Key-Based Authentication

This is the single most impactful change. Disable password authentication entirely and require cryptographic keys.

Generating a Strong Key Pair

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Ed25519 — modern, fast, secure (recommended)
ssh-keygen -t ed25519 -C "user@hostname-$(date +%Y-%m-%d)"

# ECDSA — good alternative
ssh-keygen -t ecdsa -b 521 -C "user@hostname"

# RSA — legacy compatibility (use 4096 bits minimum if required)
ssh-keygen -t rsa -b 4096 -C "user@hostname"

# Always set a passphrase when prompted.
# The passphrase encrypts the private key on disk.
# Without it, anyone who reads the file has your key.

Key type guidance:

  • Use Ed25519 for all new keys — smaller, faster, and more resistant to implementation bugs than RSA
  • Use RSA 4096 only when connecting to legacy systems that don’t support Ed25519
  • Never use DSA (insecure) or ECDSA with P-256/P-384 if you’re concerned about NIST curve backdoors

The key pair lives in ~/.ssh/:

~/.ssh/
├── id_ed25519          ← Private key (600 permissions, never share)
├── id_ed25519.pub      ← Public key (share this everywhere)
├── known_hosts         ← Server fingerprints (auto-maintained)
└── authorized_keys     ← Keys that can log into THIS account

Installing the Public Key on a Server

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# The built-in tool — copies ~/.ssh/id_ed25519.pub to the server
ssh-copy-id user@server

# Manually (if ssh-copy-id isn't available)
cat ~/.ssh/id_ed25519.pub | ssh user@server \
  "mkdir -p ~/.ssh && chmod 700 ~/.ssh && \
   cat >> ~/.ssh/authorized_keys && \
   chmod 600 ~/.ssh/authorized_keys"

# Verify it works before disabling passwords
ssh user@server   # Should log in without password prompt

Correct Permissions

SSH is strict about permissions. Wrong permissions cause silent failures:

1
2
3
4
5
6
7
8
9
# On the server
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod 600 ~/.ssh/id_ed25519        # Private key
chmod 644 ~/.ssh/id_ed25519.pub    # Public key
chown -R "$USER:$USER" ~/.ssh

# Verify
ls -la ~/.ssh/

Step 2: Harden sshd_config

The server’s main config file is /etc/ssh/sshd_config. Always make a backup before editing:

1
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup

Test your config before restarting — a syntax error can lock you out:

1
sshd -t    # Test config syntax (exit 0 = valid)

Always keep an existing session open while restarting sshd, so you can fix problems if the new config is wrong.

The Hardened sshd_config

# /etc/ssh/sshd_config — hardened configuration

# ── Protocol and Port ─────────────────────────────────────────────────────────

# Non-standard port reduces automated scan noise significantly.
# Obscurity is not security, but it cuts log spam by 99%.
Port 2222

# IPv4 and IPv6 (use 'inet' for IPv4-only, 'inet6' for IPv6-only)
AddressFamily any

# ── Authentication ────────────────────────────────────────────────────────────

# Disable password authentication entirely
PasswordAuthentication no
PermitEmptyPasswords no

# Disable keyboard-interactive (covers PAM password prompts)
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no

# Disable PAM authentication stack (prevents password bypass via PAM modules)
UsePAM no

# Allow only public key authentication
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys

# Disable root login — always use a regular user + sudo
PermitRootLogin no

# Maximum authentication attempts before disconnect
MaxAuthTries 3

# How long to allow for authentication
LoginGraceTime 30

# ── Access Control ────────────────────────────────────────────────────────────

# Restrict which users can SSH in (whitelist approach)
AllowUsers alice bob deploy-user

# Or restrict by group
# AllowGroups sshusers admins

# Deny specific users regardless of other rules
DenyUsers root

# ── Connection Limits ─────────────────────────────────────────────────────────

# Max simultaneous unauthenticated connections before refusing new ones
# Format: start:rate:full (default: 10:30:100)
# 5 max pending, 50% probability of dropping after 5, max 10 total
MaxStartups 5:50:10

# Max sessions per connection (useful for shared keys with MaxAuthTries)
MaxSessions 4

# Idle timeout — disconnect after 5 minutes of inactivity
ClientAliveInterval 300
ClientAliveCountMax 1

# ── Features to Disable ───────────────────────────────────────────────────────

# Disable X11 forwarding (rarely needed, attack surface)
X11Forwarding no

# Disable TCP forwarding unless explicitly needed
# (prevents tunnel abuse; enable selectively per user if needed)
AllowTcpForwarding no

# Disable agent forwarding by default (enable per-host in client config)
AllowAgentForwarding no

# Disable tunneling
PermitTunnel no

# Disable .rhosts and /etc/hosts.equiv authentication (ancient, insecure)
IgnoreRhosts yes
HostbasedAuthentication no

# ── Hardened Crypto ───────────────────────────────────────────────────────────

# Only allow strong ciphers (removes 3DES, RC4, CBC mode ciphers)
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr

# Only strong MACs (removes MD5, SHA1)
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com

# Only strong key exchange algorithms
KexAlgorithms curve25519-sha256@libssh.org,curve25519-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512

# Only modern host key types
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-256

# ── Logging ───────────────────────────────────────────────────────────────────

# Log authentication details (VERBOSE logs fingerprints of accepted keys)
LogLevel VERBOSE
SyslogFacility AUTH

# ── Miscellaneous ─────────────────────────────────────────────────────────────

# Don't reveal OS/version in banner
DebianBanner no

# Show last login info (useful to detect unauthorized access)
PrintLastLog yes

# Strict mode — refuse to use insecure key/config file permissions
StrictModes yes

Apply and Verify

1
2
3
4
5
6
7
8
# Test syntax
sshd -t

# Reload (graceful — doesn't drop existing connections)
systemctl reload sshd

# Confirm from a NEW terminal window (keep old one open as fallback)
ssh -p 2222 user@server

Step 3: Client Configuration (~/.ssh/config)

A well-maintained client config eliminates typos, enforces security options, and documents your server inventory.

# ~/.ssh/config

# ── Global Defaults ───────────────────────────────────────────────────────────

Host *
    # Always use the strongest available key type first
    HostKeyAlgorithms ssh-ed25519-cert-v01@openssh.com,ssh-ed25519,rsa-sha2-512,rsa-sha2-256

    # Don't forward the SSH agent by default (enable per-host as needed)
    ForwardAgent no

    # Verify host keys — never auto-accept unknown hosts
    StrictHostKeyChecking ask

    # Multiplexing — reuse existing connections for speed
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

    # Keep connections alive
    ServerAliveInterval 60
    ServerAliveCountMax 3

    # Prefer Ed25519 keys
    IdentityFile ~/.ssh/id_ed25519

    # Send as few environment variables as possible
    SendEnv LANG LC_*

# ── Specific Hosts ────────────────────────────────────────────────────────────

Host prod-web
    HostName 203.0.113.10
    User deploy
    Port 2222
    IdentityFile ~/.ssh/id_ed25519_prod
    # Never forward agent to production
    ForwardAgent no

Host staging
    HostName 203.0.113.20
    User deploy
    Port 2222
    IdentityFile ~/.ssh/id_ed25519_staging

Host homelab
    HostName 192.168.1.100
    User alice
    # Local network — can be more relaxed
    ForwardAgent yes

# ── Bastion/Jump Host Pattern ─────────────────────────────────────────────────

Host bastion
    HostName bastion.example.com
    User alice
    Port 22
    IdentityFile ~/.ssh/id_ed25519

# Internal servers accessed through the bastion
Host internal-*
    ProxyJump bastion
    User alice
    IdentityFile ~/.ssh/id_ed25519

Host internal-db
    HostName 10.0.1.50
    # Inherits ProxyJump from internal-* wildcard above

Step 4: ProxyJump — Secure Bastion Access

The modern way to access servers on internal networks is ProxyJump (replaces the older ProxyCommand with netcat).

Internet ──→ Bastion (public IP) ──→ Internal Server (private IP)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# One-off jump through bastion
ssh -J alice@bastion.example.com alice@10.0.1.50

# Multiple hops
ssh -J bastion,intermediate alice@final-destination

# In ~/.ssh/config (preferred — portable and repeatable)
Host internal-db
    HostName 10.0.1.50
    User alice
    ProxyJump bastion

# Now just:
ssh internal-db

Securing the Bastion Host

The bastion is your highest-value target — extra hardening applies:

# /etc/ssh/sshd_config on the bastion

# Only allow forwarding — no shell access for service accounts
# (Override per user with Match blocks if needed)
AllowTcpForwarding yes
X11Forwarding no

# Bastion-specific: only specific users can connect
AllowUsers alice bob ops-bot

# Rate limiting at the network level with iptables
# (Also configure fail2ban — see below)
1
2
3
# Firewall: only allow SSH from known IP ranges to the bastion
ufw allow from 203.0.113.0/24 to any port 22
ufw deny 22

Step 5: SSH Agent and Agent Forwarding

The SSH agent stores decrypted private keys in memory so you don’t type your passphrase constantly.

Using the Agent

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Start agent (usually auto-started by your desktop session)
eval "$(ssh-agent -s)"

# Add your key (prompts for passphrase once)
ssh-add ~/.ssh/id_ed25519

# Add with 4-hour expiry (key is forgotten after 4 hours)
ssh-add -t 14400 ~/.ssh/id_ed25519

# List loaded keys
ssh-add -l

# Remove a specific key
ssh-add -d ~/.ssh/id_ed25519

# Remove all keys
ssh-add -D

Why Agent Forwarding is Dangerous

ForwardAgent yes makes your local agent available on the remote server. This means:

  • Any process on the remote server running as root can use your agent
  • If the remote server is compromised, the attacker can use your agent to authenticate to other servers
  • This is lateral movement made easy

Never use ForwardAgent to production servers. Use ProxyJump instead — it never forwards your agent; it creates a direct TCP connection through the bastion.

1
2
3
4
5
# WRONG — agent forwarding to production
ssh -A user@prod-server

# RIGHT — ProxyJump (no agent forwarding)
ssh -J bastion user@prod-server

If you must use agent forwarding in a trusted environment, at minimum:

1
2
3
4
5
6
7
# Only forward to specific trusted hosts in ~/.ssh/config
Host trusted-jumphost
    ForwardAgent yes

# Use ssh-add -c to require confirmation for each agent use
ssh-add -c ~/.ssh/id_ed25519
# Will prompt: "Allow use of key id_ed25519? (yes/no)"

Step 6: SSH Certificates — Scaling Key Management

Static authorized_keys files don’t scale. When you have 50 servers and 20 users, managing who can access what with individual public keys is painful and error-prone. SSH certificates solve this.

Instead of copying a public key to every server, you:

  1. Create a Certificate Authority (CA)
  2. Sign user public keys with the CA
  3. Configure servers to trust the CA

Now any user whose key is signed by your CA can log into any server that trusts the CA — without touching authorized_keys.

Creating a Certificate Authority

1
2
3
4
5
6
# Generate the CA key pair (store this very securely — losing it requires re-enrolling everything)
ssh-keygen -t ed25519 -f /etc/ssh/ca_key -C "SSH CA $(hostname) $(date +%Y)"

# This produces:
# /etc/ssh/ca_key        ← CA private key (protect like a root password)
# /etc/ssh/ca_key.pub    ← CA public key (deploy to all servers)

Signing a User’s Public Key

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Sign alice's public key with a 30-day validity, for username "alice"
ssh-keygen -s /etc/ssh/ca_key \
  -I "alice@laptop-$(date +%Y%m%d)" \
  -n "alice,admin" \
  -V "+30d" \
  alice_id_ed25519.pub

# This creates: alice_id_ed25519-cert.pub
# Options:
# -I identity string (appears in logs)
# -n principals (comma-separated — which usernames this cert is valid for)
# -V validity period (+30d, +1w, -5m:+30d, "always" for no expiry)
# -O options (force-command, no-port-forwarding, etc.)

Configuring Servers to Trust the CA

1
2
3
4
5
# On each server, add to /etc/ssh/sshd_config:
TrustedUserCAKeys /etc/ssh/ca_key.pub

# Deploy the CA public key
scp /etc/ssh/ca_key.pub server:/etc/ssh/ca_key.pub

Alice Uses Her Certificate

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Alice's ~/.ssh/ now has:
# id_ed25519
# id_ed25519.pub
# id_ed25519-cert.pub   ← signed certificate

# SSH automatically uses the cert when it's in the same dir as the key
ssh alice@server   # Works without any authorized_keys entry

# Inspect a certificate
ssh-keygen -L -f ~/.ssh/id_ed25519-cert.pub
# Type: ssh-ed25519-cert-v01@openssh.com user certificate
# Public key: ED25519-CERT ...
# Signing CA: ED25519 ... (using ssh-ed25519)
# Key ID: "alice@laptop-20260325"
# Serial: 0
# Valid: from 2026-03-25T00:00:00 to 2026-04-24T00:00:00
# Principals: alice, admin
# Critical Options: (none)
# Extensions: permit-pty, permit-user-rc

Certificate Constraints

Force specific behavior regardless of what the user asks for:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Certificate that can only run one command
ssh-keygen -s /etc/ssh/ca_key \
  -I "deploy-bot" \
  -n "deploy" \
  -O force-command="/usr/local/bin/deploy.sh" \
  -O no-port-forwarding \
  -O no-agent-forwarding \
  -O no-pty \
  -V "+365d" \
  deploy_bot_id_ed25519.pub

This certificate, even if the private key is stolen, can only run /usr/local/bin/deploy.sh. Nothing else.

Revoking Certificates

If a certificate is compromised, add it to a revocation list:

1
2
3
4
5
6
7
8
# Create/update revocation list using the compromised key's serial or key ID
ssh-keygen -k -f /etc/ssh/revoked_keys -z 1 alice_id_ed25519.pub

# Or by key ID
ssh-keygen -k -f /etc/ssh/revoked_keys -z 1 -s /etc/ssh/ca_key alice_id_ed25519.pub

# Configure servers to check the revocation list
echo "RevokedKeys /etc/ssh/revoked_keys" >> /etc/ssh/sshd_config

Step 7: Fail2ban — Automated Attack Mitigation

Fail2ban watches log files and bans IPs that show signs of brute force attacks.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# Install
apt install fail2ban    # Ubuntu/Debian
dnf install fail2ban    # Fedora/RHEL

# Create a local config (never edit the default — it's overwritten on updates)
cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime  = 3600     # 1 hour ban
findtime = 600      # Within 10 minutes
maxretry = 3        # After 3 failures

# Use iptables for banning
banaction = iptables-multiport

# Email alerts (optional — requires mail setup)
# destemail = ops@example.com
# sendername = Fail2ban
# action = %(action_mwl)s

[sshd]
enabled  = true
port     = 2222     # Match your actual SSH port
logpath  = %(sshd_log)s
backend  = %(syslog_backend)s
maxretry = 3
bantime  = 86400    # 24-hour ban for SSH

[sshd-ddos]
enabled  = true
port     = 2222
logpath  = %(sshd_log)s
maxretry = 6
findtime = 30
bantime  = 86400
EOF

systemctl enable --now fail2ban

# Check status
fail2ban-client status sshd

# Unban an IP (if you lock yourself out)
fail2ban-client set sshd unbanip 203.0.113.5

# See banned IPs
fail2ban-client get sshd banip

Step 8: Two-Factor Authentication

For environments where even compromised keys shouldn’t be sufficient, add TOTP (time-based one-time passwords) as a second factor.

1
2
3
4
5
6
7
8
9
# Install Google Authenticator PAM module
apt install libpam-google-authenticator    # Ubuntu/Debian
dnf install google-authenticator-libpam   # Fedora/RHEL

# Set up for each user (run as the user)
google-authenticator
# Answer yes to time-based tokens
# Save the QR code / emergency codes
# Scan QR code with Authy, Google Authenticator, etc.

Configure PAM

1
2
3
4
# Add to the top of /etc/pam.d/sshd:
auth required pam_google_authenticator.so nullok
# 'nullok' allows users who haven't set up 2FA to still log in
# Remove 'nullok' to require 2FA for all users

Configure sshd for 2FA with Keys

You want to require both a key AND the TOTP code:

1
2
3
4
5
# In /etc/ssh/sshd_config:
UsePAM yes
AuthenticationMethods publickey,keyboard-interactive
KbdInteractiveAuthentication yes
# Leave PasswordAuthentication no

This requires:

  1. A valid public key (first factor)
  2. The TOTP code (second factor)

Passwords are still disabled — the TOTP prompt uses keyboard-interactive, not password auth.


Step 9: Host Key Verification

Distributing Known Hosts via DNS (SSHFP Records)

Add SSHFP DNS records so clients can verify host keys via DNS:

1
2
3
4
5
6
7
8
9
# Generate SSHFP record for your server
ssh-keygen -r server.example.com

# Output:
# server.example.com IN SSHFP 4 2 <hash>   (Ed25519)
# server.example.com IN SSHFP 1 2 <hash>   (RSA)

# Add these to your DNS zone file, then:
# Client automatically verifies via DNS (requires DNSSEC for security)
# In ~/.ssh/config to enable SSHFP verification:
Host server.example.com
    VerifyHostKeyDNS yes

Managing known_hosts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# View known host entries
cat ~/.ssh/known_hosts

# Remove a stale entry (after server rebuild)
ssh-keygen -R server.example.com
ssh-keygen -R 203.0.113.10

# Add a host key without connecting (pre-populate)
ssh-keyscan -H server.example.com >> ~/.ssh/known_hosts

# Hash all plaintext hostnames in known_hosts (prevents enumeration)
ssh-keygen -H

Step 10: Auditing and Monitoring

What to Log

With LogLevel VERBOSE in sshd_config, you get fingerprints of accepted keys — crucial for auditing which key was used for which login:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Successful logins with key fingerprint
journalctl -u sshd | grep "Accepted publickey"
# Accepted publickey for alice from 203.0.113.5 port 54321 ssh2:
#   ED25519 SHA256:AbCdEf...

# Failed attempts
journalctl -u sshd | grep "Failed"

# All auth events for a specific user
journalctl -u sshd | grep "alice"

# Real-time monitoring
journalctl -u sshd -f

SSH Access Audit Script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#!/usr/bin/env bash
# ssh-audit.sh — Summary of recent SSH activity

echo "=== SSH Access Report — $(hostname)$(date '+%Y-%m-%d') ==="
echo ""

echo "--- Successful Logins (last 24h) ---"
journalctl -u sshd --since "24 hours ago" --no-pager \
  | grep "Accepted" \
  | awk '{print $1, $2, $3, "user="$9, "from="$11, "key="$NF}' \
  | sort

echo ""
echo "--- Failed Attempts (top 10 source IPs) ---"
journalctl -u sshd --since "24 hours ago" --no-pager \
  | grep "Failed\|Invalid" \
  | grep -oP 'from \K[0-9.]+' \
  | sort | uniq -c | sort -rn | head -10 \
  | awk '{printf "  %6d  %s\n", $1, $2}'

echo ""
echo "--- Currently Logged In ---"
who

echo ""
echo "--- Recent Sessions (last 10) ---"
last -10 | grep -v "^$\|^reboot\|^wtmp"

echo ""
echo "--- Authorized Keys (all users) ---"
while IFS=: read -r user _ uid _ _ home _; do
  [[ "$uid" -ge 1000 || "$uid" -eq 0 ]] || continue
  auth_keys="${home}/.ssh/authorized_keys"
  [[ -f "$auth_keys" ]] || continue
  key_count=$(grep -c "^ssh-\|^ecdsa-\|^sk-" "$auth_keys" 2>/dev/null || echo 0)
  echo "  $user: $key_count key(s) in authorized_keys"
done < /etc/passwd

Detect Unauthorized Key Additions

Run a checksum of all authorized_keys files and alert on changes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/usr/bin/env bash
# monitor-authorized-keys.sh — Detect changes to authorized_keys files

HASH_FILE="/var/lib/ssh-key-monitor/hashes"
mkdir -p "$(dirname "$HASH_FILE")"
ALERT_EMAIL="security@example.com"
CHANGED=()

while IFS=: read -r user _ uid _ _ home _; do
    [[ "$uid" -ge 1000 || "$uid" -eq 0 ]] || continue
    auth_keys="${home}/.ssh/authorized_keys"
    [[ -f "$auth_keys" ]] || continue

    current_hash=$(md5sum "$auth_keys" | awk '{print $1}')
    stored_hash=$(grep "^${user}:" "$HASH_FILE" 2>/dev/null | cut -d: -f2)

    if [[ -z "$stored_hash" ]]; then
        echo "${user}:${current_hash}" >> "$HASH_FILE"
    elif [[ "$current_hash" != "$stored_hash" ]]; then
        CHANGED+=("$user")
        sed -i "s/^${user}:.*/${user}:${current_hash}/" "$HASH_FILE"
    fi
done < /etc/passwd

if [[ ${#CHANGED[@]} -gt 0 ]]; then
    {
        echo "Subject: [SECURITY] authorized_keys changed on $(hostname)"
        echo ""
        echo "authorized_keys was modified for: ${CHANGED[*]}"
        echo "Host: $(hostname -f)"
        echo "Time: $(date)"
        echo ""
        for user in "${CHANGED[@]}"; do
            home=$(getent passwd "$user" | cut -d: -f6)
            echo "--- $user ---"
            cat "${home}/.ssh/authorized_keys" 2>/dev/null
            echo ""
        done
    } | sendmail "$ALERT_EMAIL"
fi

Run from cron every 5 minutes.


Quick Wins Checklist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 1. Generate Ed25519 key if you don't have one
ssh-keygen -t ed25519 -C "$(whoami)@$(hostname)-$(date +%Y%m%d)"

# 2. Copy public key to server
ssh-copy-id user@server

# 3. Verify key login works, THEN disable passwords
# Edit /etc/ssh/sshd_config:
echo "PasswordAuthentication no" | sudo tee -a /etc/ssh/sshd_config
echo "PermitRootLogin no" | sudo tee -a /etc/ssh/sshd_config
sudo sshd -t && sudo systemctl reload sshd

# 4. Change SSH port
sudo sed -i 's/^#Port 22/Port 2222/' /etc/ssh/sshd_config
sudo sshd -t && sudo systemctl reload sshd

# 5. Install and configure fail2ban
sudo apt install fail2ban
# ... configure as above

# 6. Set up connection multiplexing in ~/.ssh/config
cat >> ~/.ssh/config << 'EOF'
Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
EOF
mkdir -p ~/.ssh/cm-sockets   # Store control sockets here if preferred

# 7. Audit what keys can log in
for u in $(cut -d: -f1 /etc/passwd); do
    home=$(getent passwd "$u" | cut -d: -f6)
    [[ -f "${home}/.ssh/authorized_keys" ]] && echo "$u has authorized keys"
done

# 8. Verify your sshd_config isn't using weak algorithms
ssh -vvv server 2>&1 | grep -E "cipher|mac|kex" | head -10

Security Levels Summary

Setting Essential Good Paranoid
Key-based auth only
PermitRootLogin no
Non-standard port
AllowUsers whitelist
Hardened ciphers/MACs
Fail2ban
SSH certificates
Two-factor auth (TOTP)
ForwardAgent off globally
Authorized keys monitoring
Port knocking
SSHFP DNS records

Every server exposed to the public internet should implement at minimum the “Essential” column. The “Good” column requires an hour of work and eliminates nearly all practical attack vectors. “Paranoid” is appropriate for production systems handling sensitive data or where audit requirements demand it.

The bottom line: a server with key-only authentication, no root login, and fail2ban is orders of magnitude more secure than default config. Each additional layer from there raises the bar against increasingly sophisticated attackers.

Comments