SSH Hardening: Locking Down the Door Every Server Has Open
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
|
|
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
|
|
Correct Permissions
SSH is strict about permissions. Wrong permissions cause silent failures:
|
|
Step 2: Harden sshd_config
The server’s main config file is /etc/ssh/sshd_config. Always make a backup before editing:
|
|
Test your config before restarting — a syntax error can lock you out:
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
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.
|
|
If you must use agent forwarding in a trusted environment, at minimum:
|
|
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:
- Create a Certificate Authority (CA)
- Sign user public keys with the CA
- 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
|
|
Signing a User’s Public Key
|
|
Configuring Servers to Trust the CA
|
|
Alice Uses Her Certificate
|
|
Certificate Constraints
Force specific behavior regardless of what the user asks for:
|
|
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:
|
|
Step 7: Fail2ban — Automated Attack Mitigation
Fail2ban watches log files and bans IPs that show signs of brute force attacks.
|
|
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.
|
|
Configure PAM
|
|
Configure sshd for 2FA with Keys
You want to require both a key AND the TOTP code:
|
|
This requires:
- A valid public key (first factor)
- 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:
|
|
# In ~/.ssh/config to enable SSHFP verification:
Host server.example.com
VerifyHostKeyDNS yes
Managing known_hosts
|
|
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:
|
|
SSH Access Audit Script
|
|
Detect Unauthorized Key Additions
Run a checksum of all authorized_keys files and alert on changes:
|
|
Run from cron every 5 minutes.
Quick Wins Checklist
|
|
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