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.
SSH Authentication Fundamentals
Section titled “SSH Authentication Fundamentals”Password Auth vs Key Auth
Section titled “Password Auth vs Key Auth”| Factor | Password auth | Key auth |
|---|---|---|
| What’s shared | Hash stored server-side; password sent over encrypted channel | Only the public key is on the server; private key never leaves client |
| Brute-forceable | Yes - automated spray/brute-force attacks are continuous | No - mathematically infeasible to derive private key from public key |
| Phishable | Yes - tricked into entering on fake site | No - key auth never transmits a secret |
| Lost credential | Attacker can use it from anywhere | Private key is on your device; attacker needs both key + passphrase |
| Recommendation | Disable in production | ✅ Use always |
Key Generation
Section titled “Key Generation”# 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 keyssh-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)Installing a Public Key on a Server
Section titled “Installing a Public Key on a Server”# Method 1: ssh-copy-id (easiest)
# 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 alreadycat id_ed25519.pub >> /home/alice/.ssh/authorized_keyschown alice:alice /home/alice/.ssh/authorized_keyschmod 600 /home/alice/.ssh/authorized_keysSSH Agent
Section titled “SSH Agent”The agent holds your decrypted private key in memory - you enter the passphrase once per session:
# Start the agent and load your keyeval $(ssh-agent -s)ssh-add ~/.ssh/id_ed25519 # prompts for passphrase once
# List loaded keysssh-add -l
# Remove all keys from agentssh-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)sshd_config Hardening
Section titled “sshd_config Hardening”The server-side configuration lives at /etc/ssh/sshd_config. Apply these settings:
# /etc/ssh/sshd_config - production hardening
# --- Protocol and algorithms ---Protocol 2 # SSH v1 is broken - explicitly enforce v2Port 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_keyHostKey /etc/ssh/ssh_host_rsa_key
# --- Authentication ---PermitRootLogin no # never allow direct root SSHPasswordAuthentication no # DISABLE password authPubkeyAuthentication yes # enable key authAuthorizedKeysFile .ssh/authorized_keysPermitEmptyPasswords no # disallow empty-password accountsChallengeResponseAuthentication 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 attemptsMaxSessions 5 # max concurrent sessions per connectionLoginGraceTime 30 # seconds to authenticate before disconnectClientAliveInterval 300 # send keepalive every 5 minClientAliveCountMax 2 # disconnect after 2 missed keepalives (10 min idle)
# --- Features to disable ---X11Forwarding no # X11 forwarding is rarely needed; attack surfaceAllowTcpForwarding no # disable unless you specifically need port forwardingAllowStreamLocalForwarding no # disable Unix socket forwardingGatewayPorts no # prevent binding remote ports on all interfacesPermitTunnel no # disable VPN-like tunnellingPermitUserEnvironment no # prevent users overriding env variables
# --- Logging ---LogLevel VERBOSE # log key fingerprints on authenticationSyslogFacility 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# Test config for syntax errors before reloadingsshd -t
# Reload without dropping existing connectionssystemctl reload sshd
# Check what algorithms your sshd currently supportsnmap --script ssh2-enum-algos -p 22 localhostssh -Q kex # list supported key exchange algorithmsssh -Q cipher # list supported ciphersssh -Q mac # list supported MACsClient-Side: ~/.ssh/config
Section titled “Client-Side: ~/.ssh/config”The client config saves typing and enforces consistency:
# Default settings for all hostsHost * 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 hostHost 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 keysHost 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# 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 hostrsync -avz -e ssh internal-server:/data/ ./backup/Jump Hosts (Bastion Hosts)
Section titled “Jump Hosts (Bastion Hosts)”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# 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 bastionJump Host Hardening
Section titled “Jump Host Hardening”# /etc/ssh/sshd_config on the bastionAllowTcpForwarding yes # ProxyJump needs thisGatewayPorts no # don't bind on all interfacesPermitOpen internal-web.10.0.0.10:22 db.10.0.0.20:22 # restrict WHICH hosts can be reachedX11Forwarding noAllowAgentForwarding no # IMPORTANT on bastion: don't allow agent forwardingPort Forwarding
Section titled “Port Forwarding”SSH can tunnel other protocols through an encrypted channel - useful for secure access to services that don’t have their own encryption.
# 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 firewallssh -L 5432:db.internal:5432 alice@bastionpsql -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 servicessh -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 proxyssh -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)Session Auditing and Logging
Section titled “Session Auditing and Logging”# View SSH login events in auth logjournalctl -u sshd # systemd journalgrep "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 IPgrep "Accepted" /var/log/auth.log
# Active SSH sessionswho # current logged-in usersw # users + what they're doinglast | head -20 # recent login historylastb | 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 --exectlog - Session Recording
Section titled “tlog - Session Recording”# Record complete terminal sessions for compliance/auditapt 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 sessionjournalctl -o json | tldr replay # from systemd journal# or with Teleport (enterprise session recording tool)Multi-Factor Authentication for SSH
Section titled “Multi-Factor Authentication for SSH”MFA adds a TOTP (time-based OTP) requirement on top of key auth:
# Install Google Authenticator PAM moduleapt install libpam-google-authenticator
# Run setup for each usergoogle-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 codeAuthenticationMethods publickey,keyboard-interactive # require BOTH key + TOTP
systemctl reload sshdCommon SSH Security Mistakes
Section titled “Common SSH Security Mistakes”| Mistake | Why it’s bad | Fix |
|---|---|---|
| Root login enabled | Attacker gets root immediately on credential compromise | PermitRootLogin no |
| Password auth enabled | Continuous brute-force attacks against password | PasswordAuthentication no |
| Private key without passphrase | Stolen key file = instant access | Always use passphrase + ssh-agent |
| Old/weak host keys | DSA keys and small RSA keys are breakable | Remove DSA; use Ed25519 host keys |
| Agent forwarding to untrusted hosts | Compromised host can use your agent | Use ProxyJump instead; disable AgentForwarding |
| Permissive AllowTcpForwarding | Creates an uncontrolled tunnel endpoint | AllowTcpForwarding no unless needed |
| No IP restriction on SSH | Internet-wide brute force access | AllowUsers + firewall to management IPs |
| Not monitoring auth.log | Silent ongoing brute-force | fail2ban + SIEM alerting |
| Sharing SSH keys between users | No individual accountability | One key pair per person per purpose |
| Using same key everywhere | One compromise affects all servers | Separate keys per environment (prod/staging/bastion) |