SSH is the front door to every Linux server. It is also the most attacked service on the internet — any server with port 22 open receives thousands of login attempts per day from automated scanners. A poorly configured SSH daemon is one of the most common entry points for unauthorized access.
This page covers hardening SSH from the
ground up: key-based authentication, disabling password login,
hardening sshd_config, restricting access, setting up
SSH multiplexing, and monitoring for suspicious activity.
# Generate a modern Ed25519 key pair (recommended) ssh-keygen -t ed25519 -C "craig@binghamton.edu" # Generate RSA 4096-bit (for older systems that don't support Ed25519) ssh-keygen -t rsa -b 4096 -C "craig@binghamton.edu" # Generate with a specific filename ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_bambustudio -C "bambustudio key" # View your public key cat ~/.ssh/id_ed25519.pub # Copy public key to a remote server ssh-copy-id user@server ssh-copy-id -i ~/.ssh/id_ed25519.pub user@server ssh-copy-id -p 2222 user@server # non-standard port # Manual method if ssh-copy-id not 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 key permissions (must be correct or SSH ignores them) chmod 700 ~/.ssh chmod 600 ~/.ssh/id_ed25519 chmod 644 ~/.ssh/id_ed25519.pub chmod 600 ~/.ssh/authorized_keys
The most impactful SSH security changes are in
/etc/ssh/sshd_config. Always keep a backup and
test before disconnecting:
# Backup first sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.orig # Edit the config sudo vi /etc/ssh/sshd_config
# === Hardened sshd_config settings === # Change default port (reduces automated scan noise) Port 2222 # Only use SSH protocol 2 (protocol 1 is broken) Protocol 2 # Disable root login -- use a named account + sudo instead PermitRootLogin no # Disable password authentication -- keys only PasswordAuthentication no ChallengeResponseAuthentication no UsePAM yes # Disable empty passwords PermitEmptyPasswords no # Only allow specific users or groups AllowUsers craig alice AllowGroups sysadmin developers # Limit authentication attempts MaxAuthTries 3 MaxSessions 5 # Set login grace time (seconds to authenticate) LoginGraceTime 30 # Disconnect idle sessions after 15 minutes ClientAliveInterval 300 ClientAliveCountMax 3 # Disable X11 forwarding (unless needed) X11Forwarding no # Disable agent forwarding (unless needed) AllowAgentForwarding no # Restrict to strong ciphers and MACs 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 # Log level for security monitoring LogLevel VERBOSE
# Test config before restarting sudo sshd -t echo "Config OK: $?" # Restart SSH (keep existing session open until verified!) sudo systemctl restart sshd # Test from another terminal BEFORE closing current session ssh -p 2222 craig@server
sshd -t first,
then verify the new connection works before closing the old one.
A well-configured client makes connecting to multiple servers faster, safer, and more convenient:
# ~/.ssh/config -- client-side SSH configuration # Global defaults Host * ServerAliveInterval 60 ServerAliveCountMax 3 IdentitiesOnly yes AddKeysToAgent yes # Specific server aliases Host bambustudio HostName 192.168.1.100 User craig Port 22 IdentityFile ~/.ssh/id_ed25519_bambustudio Host centosjump HostName 192.168.1.50 User craig Port 2222 IdentityFile ~/.ssh/id_ed25519 Host prod-web HostName web01.example.com User deploy Port 22 IdentityFile ~/.ssh/id_ed25519_prod ForwardAgent no # Jump host / bastion configuration Host internal-db HostName 10.0.1.20 User dbadmin ProxyJump bastion.example.com # Now connect with just: ssh bambustudio ssh centosjump ssh internal-db # auto-tunnels through bastion
ssh -p 2222 -i ~/.ssh/id_ed25519_bambustudio craig@192.168.1.100
— just ssh bambustudio. Consistent aliases also
mean you never accidentally connect to the wrong server.
# List keys loaded in SSH agent ssh-add -l # Add a key to the agent ssh-add ~/.ssh/id_ed25519 ssh-add -t 3600 ~/.ssh/id_ed25519 # expire in 1 hour # Remove all keys from agent ssh-add -D # View authorized_keys on a server cat ~/.ssh/authorized_keys # Remove a specific key from authorized_keys # Edit the file and delete the line, or: ssh-keygen -R hostname # remove from known_hosts # Rotate SSH keys -- generate new, deploy, remove old ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_new -C "new key $(date +%Y%m%d)" ssh-copy-id -i ~/.ssh/id_ed25519_new.pub user@server # Test login with new key, then remove old key from authorized_keys # Audit authorized_keys across all users sudo find /home /root -name "authorized_keys" -exec echo "=== {} ===" \; \ -exec cat {} \; 2>/dev/null # Check key fingerprints ssh-keygen -lf ~/.ssh/authorized_keys ssh-keygen -lf ~/.ssh/id_ed25519.pub
# Method 1: AllowUsers with from restriction in sshd_config # Only allow craig to login from specific IP range AllowUsers craig@192.168.1.* AllowUsers alice@10.0.0.0/8 # Method 2: TCP Wrappers (/etc/hosts.allow and /etc/hosts.deny) # /etc/hosts.allow: sudo tee /etc/hosts.allow << 'EOF' sshd: 192.168.1.0/255.255.255.0 sshd: 10.0.0.0/255.0.0.0 EOF # /etc/hosts.deny: sudo tee /etc/hosts.deny << 'EOF' sshd: ALL EOF # Method 3: firewalld -- allow SSH only from management network sudo firewall-cmd --zone=public --remove-service=ssh --permanent sudo firewall-cmd --zone=public \ --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" service name="ssh" accept' \ --permanent sudo firewall-cmd --reload # Method 4: authorized_keys from= restriction # In authorized_keys, prefix key with from= to restrict by IP: # from="192.168.1.*" ssh-ed25519 AAAA... craig@workstation
# Local port forwarding -- access remote service locally # Access remote MySQL (port 3306) via local port 3307 ssh -L 3307:localhost:3306 user@dbserver # Now: mysql -h 127.0.0.1 -P 3307 -u root -p # Remote port forwarding -- expose local service on remote server # Make local port 8080 accessible on remote server port 9090 ssh -R 9090:localhost:8080 user@remoteserver # Dynamic forwarding -- SOCKS proxy through SSH ssh -D 1080 user@remoteserver # Configure browser to use SOCKS5 proxy at localhost:1080 # Jump host -- reach an internal server through a bastion ssh -J bastion.example.com user@internal-server # Background tunnel (persistent) ssh -fNL 3307:localhost:3306 user@dbserver # -f = background, -N = no command, -L = local forward # Disable tunneling in sshd_config if not needed # AllowTcpForwarding no # GatewayPorts no
AllowTcpForwarding no and GatewayPorts no
in sshd_config.
# Watch live SSH connections sudo journalctl -u sshd -f # View all successful logins today sudo journalctl -u sshd --since today | grep "Accepted" # Count failed attempts by source IP sudo journalctl -u sshd --since "7 days ago" | \ grep "Failed password" | \ awk '{print $(NF-3)}' | sort | uniq -c | sort -rn | head -20 # View currently connected SSH sessions sudo ss -tnp | grep sshd who w # List all SSH sessions with PIDs sudo ps aux | grep sshd | grep -v grep # Terminate a specific SSH session sudo kill -HUP PID # Check for new authorized_keys additions (via auditd) sudo ausearch -f /root/.ssh/authorized_keys sudo ausearch -f /home/alice/.ssh/authorized_keysjournalctl sshd successful login example:
#!/bin/bash
# ssh-audit.sh -- verify SSH hardening settings
SSHD_CONFIG="/etc/ssh/sshd_config"
PASS=0
FAIL=0
check() {
local desc=$1
local expected=$2
local actual=$3
if echo "$actual" | grep -qi "$expected"; then
echo " [PASS] $desc"
((PASS++))
else
echo " [FAIL] $desc (found: $actual)"
((FAIL++))
fi
}
echo "=== SSH Security Audit: $(hostname) ==="
echo ""
check "Root login disabled" \
"PermitRootLogin no" \
"$(grep -i PermitRootLogin $SSHD_CONFIG)"
check "Password auth disabled" \
"PasswordAuthentication no" \
"$(grep -i PasswordAuthentication $SSHD_CONFIG)"
check "Empty passwords disabled" \
"PermitEmptyPasswords no" \
"$(grep -i PermitEmptyPasswords $SSHD_CONFIG)"
check "X11 forwarding disabled" \
"X11Forwarding no" \
"$(grep -i X11Forwarding $SSHD_CONFIG)"
check "Max auth tries set" \
"MaxAuthTries [1-5]" \
"$(grep -i MaxAuthTries $SSHD_CONFIG)"
check "Client alive interval set" \
"ClientAliveInterval" \
"$(grep -i ClientAliveInterval $SSHD_CONFIG)"
check "SSH running" \
"active" \
"$(systemctl is-active sshd)"
echo ""
echo "Results: $PASS passed, $FAIL failed"
[ $FAIL -eq 0 ] && echo "All checks passed!" || echo "Fix failing items above."
| Setting | Recommended Value | Why |
|---|---|---|
| PermitRootLogin | no | Force use of named accounts with sudo |
| PasswordAuthentication | no | Keys only — eliminates password brute force |
| PermitEmptyPasswords | no | Never allow empty password login |
| MaxAuthTries | 3 | Limit guessing attempts per connection |
| LoginGraceTime | 30 | 30 seconds to authenticate then disconnect |
| ClientAliveInterval | 300 | Disconnect idle sessions after 15 min |
| X11Forwarding | no | Disable unless GUI forwarding needed |
| AllowAgentForwarding | no | Disable unless acting as jump host |
| AllowTcpForwarding | no | Disable unless tunneling needed |
| LogLevel | VERBOSE | Log key fingerprints for audit trail |
| Command | What it does |
|---|---|
| ssh-keygen -t ed25519 -C "comment" | Generate Ed25519 key pair |
| ssh-copy-id user@server | Deploy public key to server |
| ssh-add -l | List keys in SSH agent |
| sudo sshd -t | Test sshd_config syntax |
| sudo systemctl restart sshd | Apply config changes |
| ssh -L local:host:remote user@server | Local port forward |
| ssh -J bastion user@internal | Jump through bastion host |
| journalctl -u sshd -f | Monitor SSH activity live |
| ssh-keygen -lf ~/.ssh/authorized_keys | Show key fingerprints |