Skip to content

SSH Hardening

SSH (Secure Shell) is the primary remote access protocol for Unix/Linux systems. A default SSH installation is a common attack target - password authentication, root login, and default port exposure all expand your attack surface. This note covers key-based authentication, sshd_config hardening, jump hosts, port forwarding security, and session auditing.


FactorPassword authKey auth
What’s sharedHash stored server-side; password sent over encrypted channelOnly the public key is on the server; private key never leaves client
Brute-forceableYes - automated spray/brute-force attacks are continuousNo - mathematically infeasible to derive private key from public key
PhishableYes - tricked into entering on fake siteNo - key auth never transmits a secret
Lost credentialAttacker can use it from anywherePrivate key is on your device; attacker needs both key + passphrase
RecommendationDisable in production✅ Use always
Terminal window
# Generate an Ed25519 key pair (modern, fast, small)
ssh-keygen -t ed25519 -C "alice@workstation" -f ~/.ssh/id_ed25519
# Passphrase protects the private key at rest - use one
# Generate RSA 4096 (for legacy systems that don't support Ed25519)
ssh-keygen -t rsa -b 4096 -C "alice@workstation" -f ~/.ssh/id_rsa_4096
# View fingerprint of a key (to verify identity)
ssh-keygen -lf ~/.ssh/id_ed25519.pub # your public key
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub # server host key
# Files created:
# ~/.ssh/id_ed25519 ← private key (NEVER share; passphrase-protected)
# ~/.ssh/id_ed25519.pub ← public key (share freely; put on servers)
Terminal window
# Method 1: ssh-copy-id (easiest)
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]
# Method 2: manual append (when ssh-copy-id isn't available)
cat ~/.ssh/id_ed25519.pub | ssh alice@server 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
# Method 3: if you have sudo/root on the server already
cat id_ed25519.pub >> /home/alice/.ssh/authorized_keys
chown alice:alice /home/alice/.ssh/authorized_keys
chmod 600 /home/alice/.ssh/authorized_keys

The agent holds your decrypted private key in memory - you enter the passphrase once per session:

Terminal window
# Start the agent and load your key
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519 # prompts for passphrase once
# List loaded keys
ssh-add -l
# Remove all keys from agent
ssh-add -D
# Set key lifetime in agent (auto-removes after 4 hours)
ssh-add -t 14400 ~/.ssh/id_ed25519
# Forwarding agent to a jump host (enables onward key auth)
ssh -A jumphost.example.com
# Then from jumphost: ssh [email protected] (uses forwarded agent)

The server-side configuration lives at /etc/ssh/sshd_config. Apply these settings:

Terminal window
# /etc/ssh/sshd_config - production hardening
# --- Protocol and algorithms ---
Protocol 2 # SSH v1 is broken - explicitly enforce v2
Port 22 # changing port reduces noise but isn't security
#Port 2222 # optional: non-standard port
# --- Host keys (use Ed25519 and RSA only; remove DSA/ECDSA-256) ---
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
# --- Authentication ---
PermitRootLogin no # never allow direct root SSH
PasswordAuthentication no # DISABLE password auth
PubkeyAuthentication yes # enable key auth
AuthorizedKeysFile .ssh/authorized_keys
PermitEmptyPasswords no # disallow empty-password accounts
ChallengeResponseAuthentication no # disables keyboard-interactive (inc. PAM pw)
UsePAM yes # keep PAM for other modules (MFA, limits)
# --- Allow only specific users/groups ---
AllowUsers alice bob # whitelist specific users
#AllowGroups ssh-users # or whitelist a group
# --- Connection limits ---
MaxAuthTries 3 # lock out after 3 failed attempts
MaxSessions 5 # max concurrent sessions per connection
LoginGraceTime 30 # seconds to authenticate before disconnect
ClientAliveInterval 300 # send keepalive every 5 min
ClientAliveCountMax 2 # disconnect after 2 missed keepalives (10 min idle)
# --- Features to disable ---
X11Forwarding no # X11 forwarding is rarely needed; attack surface
AllowTcpForwarding no # disable unless you specifically need port forwarding
AllowStreamLocalForwarding no # disable Unix socket forwarding
GatewayPorts no # prevent binding remote ports on all interfaces
PermitTunnel no # disable VPN-like tunnelling
PermitUserEnvironment no # prevent users overriding env variables
# --- Logging ---
LogLevel VERBOSE # log key fingerprints on authentication
SyslogFacility AUTH # log to auth facility → /var/log/auth.log
# --- Banner (legal notice) ---
Banner /etc/ssh/ssh-banner.txt # shown before authentication
# --- Crypto hardening (restrict to modern algorithms) ---
KexAlgorithms curve25519-sha256,[email protected],diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
HostKeyAlgorithms ssh-ed25519,[email protected],rsa-sha2-512,rsa-sha2-256
Terminal window
# Test config for syntax errors before reloading
sshd -t
# Reload without dropping existing connections
systemctl reload sshd
# Check what algorithms your sshd currently supports
nmap --script ssh2-enum-algos -p 22 localhost
ssh -Q kex # list supported key exchange algorithms
ssh -Q cipher # list supported ciphers
ssh -Q mac # list supported MACs

The client config saves typing and enforces consistency:

~/.ssh/config
# Default settings for all hosts
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
ConnectTimeout 10
ControlMaster auto # connection multiplexing (reuse existing connections)
ControlPath ~/.ssh/cm_%r@%h:%p
ControlPersist 10m # keep master connection alive for 10 min after last session
# Production bastion / jump host
Host bastion
HostName bastion.example.com
User alice
IdentityFile ~/.ssh/id_ed25519_bastion
IdentitiesOnly yes # only use the specified key; don't try others
# Internal server reached via bastion (ProxyJump - secure alternative to -A)
Host internal-server
HostName 10.0.0.50
User alice
IdentityFile ~/.ssh/id_ed25519_internal
ProxyJump bastion # SSH tunnels through bastion transparently
# Server with non-standard port and old RSA keys
Host legacy-host
HostName legacy.example.com
Port 2222
User sysadmin
IdentityFile ~/.ssh/id_rsa_legacy
HostKeyAlgorithms +ssh-rsa # allow legacy RSA if server doesn't support Ed25519
Terminal window
# With config above, connect to internal-server in one command
# (automatically tunnels through bastion)
ssh internal-server
# SCP through jump host (uses ProxyJump from config)
scp -r internal-server:/var/log/app.log ./
# rsync through jump host
rsync -avz -e ssh internal-server:/data/ ./backup/

A jump host is a hardened host that sits at a network boundary. All access to internal systems flows through it - creating a single audit point.

Internet
[bastion.example.com] ← only this host has port 22 open to the internet
│ hardened, MFA-required, fully logged
├──── ssh → internal-web.10.0.0.10
├──── ssh → db.10.0.0.20
└──── ssh → router.10.0.0.1
Terminal window
# ProxyJump (recommended - transparent single command)
# Multiple hop ProxyJump
# Equivalent via config (cleaner)
Host target
HostName 10.0.0.10
ProxyJump bastion
# Old style ProxyCommand (still works, more flexible)
Host target
ProxyCommand ssh -W %h:%p bastion
Terminal window
# /etc/ssh/sshd_config on the bastion
AllowTcpForwarding yes # ProxyJump needs this
GatewayPorts no # don't bind on all interfaces
PermitOpen internal-web.10.0.0.10:22 db.10.0.0.20:22 # restrict WHICH hosts can be reached
X11Forwarding no
AllowAgentForwarding no # IMPORTANT on bastion: don't allow agent forwarding

SSH can tunnel other protocols through an encrypted channel - useful for secure access to services that don’t have their own encryption.

Terminal window
# Local port forwarding - access remote service locally
# Forward localhost:8080 → remote-host:80 (accessed FROM your workstation)
ssh -L 8080:remote-host:80 alice@bastion
# Open browser to http://localhost:8080 → reaches remote-host:80 through ssh tunnel
# Access a database behind a firewall
ssh -L 5432:db.internal:5432 alice@bastion
psql -h localhost -p 5432 -U dbuser mydb # connects through tunnel
# Remote port forwarding - expose local service to remote host
# Creates port on remote that forwards to your local service
ssh -R 8080:localhost:3000 alice@remote-host
# From remote-host: curl http://localhost:8080 → your local port 3000
# Dynamic port forwarding - SOCKS proxy
# Routes any traffic through the SSH tunnel as a proxy
ssh -D 1080 alice@bastion
# Configure browser SOCKS5 proxy to localhost:1080
# All browser traffic routes through bastion's network
# Persistent tunnel (background, no shell)
ssh -fN -L 5432:db.internal:5432 alice@bastion
# -f = background, -N = don't execute command (tunnel only)

Terminal window
# View SSH login events in auth log
journalctl -u sshd # systemd journal
grep "sshd" /var/log/auth.log # classic syslog
# Failed SSH attempts (brute force monitoring)
grep "Failed password" /var/log/auth.log | awk '{print $9}' | sort | uniq -c | sort -rn | head
# Successful logins with source IP
grep "Accepted" /var/log/auth.log
# Active SSH sessions
who # current logged-in users
w # users + what they're doing
last | head -20 # recent login history
lastb | head -10 # failed login attempts
# Detailed process auditing with auditd
# Log all commands run in SSH sessions
# /etc/audit/rules.d/sshd-audit.rules:
-a always,exit -F arch=b64 -S execve -k ssh_commands
# Then query:
ausearch -k ssh_commands -ts recent | aureport --exec
/etc/tlog/tlog-rec-session.conf
# Record complete terminal sessions for compliance/audit
apt install tlog
{
"writer": "journal",
"shell": "/bin/bash"
}
# Configure in /etc/pam.d/sshd or /etc/pam.d/login:
session required pam_exec.so /usr/libexec/tlog/tlog-rec-session
# Replay a recorded session
journalctl -o json | tldr replay # from systemd journal
# or with Teleport (enterprise session recording tool)

MFA adds a TOTP (time-based OTP) requirement on top of key auth:

Terminal window
# Install Google Authenticator PAM module
apt install libpam-google-authenticator
# Run setup for each user
google-authenticator
# Answer Y to all prompts; scan QR code into authenticator app
# Saves secret to ~/.google_authenticator
# Configure PAM to require TOTP
# /etc/pam.d/sshd - add this line BEFORE other auth lines:
auth required pam_google_authenticator.so
# /etc/ssh/sshd_config:
ChallengeResponseAuthentication yes # needed to prompt for TOTP code
AuthenticationMethods publickey,keyboard-interactive # require BOTH key + TOTP
systemctl reload sshd

MistakeWhy it’s badFix
Root login enabledAttacker gets root immediately on credential compromisePermitRootLogin no
Password auth enabledContinuous brute-force attacks against passwordPasswordAuthentication no
Private key without passphraseStolen key file = instant accessAlways use passphrase + ssh-agent
Old/weak host keysDSA keys and small RSA keys are breakableRemove DSA; use Ed25519 host keys
Agent forwarding to untrusted hostsCompromised host can use your agentUse ProxyJump instead; disable AgentForwarding
Permissive AllowTcpForwardingCreates an uncontrolled tunnel endpointAllowTcpForwarding no unless needed
No IP restriction on SSHInternet-wide brute force accessAllowUsers + firewall to management IPs
Not monitoring auth.logSilent ongoing brute-forcefail2ban + SIEM alerting
Sharing SSH keys between usersNo individual accountabilityOne key pair per person per purpose
Using same key everywhereOne compromise affects all serversSeparate keys per environment (prod/staging/bastion)