SSH Hardening: 5-Layer Defense
SSH on port 22 is the most-scanned port on the internet. Your server sees an average of thousands of attempts per day, not hundreds of millions — the volume comes from broad background scanning, and Fail2ban's job is to make that background noise stop mattering. The five layers below compound: each one cuts the attack surface for the next.
The 5 layers: 1. Authentication — keys only, no password 2. Access control — no root, whitelist users 3. Exposure — non-standard port, host-based firewall 4. Logging — verbose logs, Fail2ban, alerting 5. Host — PAM, MFA, agent-forwarding, host key rotation
Do them in order. Each layer assumes the previous is done.
Safety Workflow: The 3 Steps That Save You
Every hardening failure I've seen traces back to skipping this. Memorize it:
Step 1 — keep a second session open. Never log in, harden, and close the only session you have. Two terminals, two SSH sessions. If the second fails, the first is still live.
Step 2 — add another machine's key first. Before changing any sshd_config, scp a key from a different machine to this host's ~/.ssh/authorized_keys for the same user. If you somehow get locked out, this key is your off-ramp.
Step 3 — test with ssh -v before closing. After changing config, open a third terminal and try to log in with the new settings. ssh -v shows the whole handshake — if it succeeds, you can safely close the old sessions.
The failure mode without this: you close the only session that works, then find out you've locked yourself out. Cloud console (VNC / serial) is the escape hatch, but not every server has one, and not every datacenter gives you one.
Layer 1: Authentication (Highest Priority)
Everything else is decoration without this. Two settings in sshd_config:
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
UsePAM no # if you don't need sudo/PAM; else: yes
Test before reload:
The three settings that look similar and mean different things:
PasswordAuthentication nodisables password logins for SSH.KbdInteractiveAuthentication nodisables the interactive keyboard challenge (some distros use this for password auth via PAM).ChallengeResponseAuthentication nois the older name forKbdInteractiveAuthentication— keep both set tonoon older OpenSSH versions.
Leaving any of them at default (or missing them in older OpenSSH) means password auth is still on. This is the single most common misconfiguration.
Key Management Basics
# Generate an Ed25519 key (modern default, faster, shorter)
ssh-keygen -t ed25519 -C "user@machine" -f ~/.ssh/id_ed25519
# Deploy the public key to the server
ssh-copy-id user@server
# Or manually:
cat ~/.ssh/id_ed25519.pub | ssh user@server "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
~/.ssh permissions matter. If they're wrong, SSH refuses:
Ownership matters too — must be owned by the user, not root, not a group:
Common trap: rsync from another machine without --preserve-perms, and ~/.ssh ends up 755. Login will silently fail with Permission denied — check sshd debug logs:
Layer 2: Access Control
Three settings that look similar and are all necessary.
Block direct root login:
Alternatives if you truly need root access:
- PermitRootLogin prohibit-password — root login allowed but only via key
- PermitRootLogin forced-commands-only — root only allowed with a specific command (e.g., a restricted admin SSH key)
Both are still weaker than no. Prefer no + sudo for regular users.
Deny common bad guys (defense in depth):
Or the inverse — a denylist of known-bad usernames that scanners try:
AllowUsers whitelist (strongest form):
Note the user@source syntax — this restricts by both user and source IP range. A user from the wrong subnet can't log in even with a valid key. This is the piece people miss: AllowUsers alone without source restriction still allows anyone in the world to try.
Layer 3: Exposure (Port + Network)
Non-Standard Port
Change port 22 to something less common:
Update the firewall at the same time:
# ufw
sudo ufw allow 2222/tcp && sudo ufw deny 22/tcp
# firewalld
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --permanent --remove-port=ssh # or 22/tcp
sudo firewall-cmd --reload
# nftables / iptables
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j DROP
Reality check on port change: it doesn't stop a determined attacker (nmap finds the port in seconds). What it does is eliminate ~95% of background noise. Measure with Fail2ban logs — after changing ports, you'll typically see a 95%+ drop in blocked IPs.
Cost: bastion hosts, VPNs, monitoring, and CI/CD pipelines all need updating. Do this last among the five layers — after key auth, root blocking, and Fail2ban are in place.
Firewall at the Network Layer
Before sshd sees the connection, the firewall should drop it. This is separate from port hardening — even a port-22 server behind a firewall is exposed to the SSH handshake.
Allow only known sources, if possible:
# ufw — restrict SSH to your office IP and bastion
sudo ufw allow from 203.0.113.0/24 to any port 2222 proto tcp
sudo ufw allow from 198.51.100.5 to any port 2222 proto tcp # bastion
sudo ufw deny 2222/tcp # deny others
If you can't restrict sources, restrict protocols and rates at the firewall:
# Limit new SSH connections per IP
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW -m recent --set
sudo iptables -A INPUT -p tcp --dport 2222 -m state --state NEW -m recent --update --seconds 60 --hitcount 5 -j DROP
This drops the 6th new connection from any IP within 60 seconds. Legitimate users with 2-3 connections per minute won't notice; scanners will get blocked before reaching sshd.
Layer 4: Logging + Fail2ban
Verbose Logging
The four LogLevel values in practice:
- QUIET — useless for security
- ERROR — misses pre-auth failures (bad)
- INFO — catches most auth failures (default, okay)
- VERBOSE — captures everything, including pre-auth probes (recommended)
VERBOSE is chatty — expect more disk. On a busy server, ship logs off-box:
# systemd-journal-remote, fluentd, rsyslog forward, etc.
sudo systemctl enable systemd-journal-remote
Fail2ban Setup
Configure the SSH jail — extend defaults:
# /etc/fail2ban/jail.local
[sshd]
enabled = true
port = 2222
filter = sshd
backend = systemd
mode = aggressive # bans on pre-auth activity too
bantime = 1h
findtime = 10m
maxretry = 3
The 5 Fail2ban jails worth enabling:
| Jail | Purpose | When to enable |
|---|---|---|
sshd |
Standard SSH brute force | Always |
sshd-ddos |
Slow scanners / DDoS probes | Always on internet-facing |
sshd-pam |
PAM-level auth failures | If UsePAM yes |
nginx-botsearch |
Nginx bot scanning | If Nginx in front |
recycler |
Cleanup of stale ban entries | Always |
Watch fail2ban's own log — a healthy jail bans IP ranges, not just IPs:
Anti-lockout guardrail: put your bastion's IP on the ignoreip list. If Fail2ban is misconfigured and starts banning you, this is your escape:
Layer 5: Host-Level Hardening
PAM
sshd can either use PAM (UsePAM yes) or not. Most distros default to yes because sudo integrates with PAM.
If you want pure key auth with no PAM involvement:
This is the cleanest config. Downside: you lose sudo integration unless sudo itself talks directly to SSH agent. On most setups, this works because sudo queries the PAM modules directly, not through sshd.
If you must use PAM, configure /etc/pam.d/sshd carefully — disable modules that do password auth even when the user claims key auth:
auth required pam_nologin.so
# auth optional pam_permit.so # DO NOT ENABLE
# auth sufficient pam_unix.so # DO NOT ENABLE (allows password fallback)
MFA (Optional but Recommended)
Google Authenticator via PAM:
sshd_config:
This is the one case where you want KbdInteractiveAuthentication enabled — it's what serves the one-time code prompt. If you enable MFA, add Match Group restrictions so only MFA-registered users are prompted.
Agent Forwarding
If you enable AllowAgentForwarding yes, a user with key access on this host can use their SSH agent from their laptop. That's a lateral-movement risk. Disable unless needed:
Host Key Rotation
Every 90 days, rotate host keys. Attackers who phish a private key from the client's known_hosts cache (or compromise a workstation) can impersonate your server. Rotate and force re-fingerprint:
Clients will need to re-accept the fingerprint. Announce the change via your change management system before executing.
5 Common Self-Lockout Traps
Trap 1: Changed sshd_config, closed the only session, can't get back in.
- Fix: cloud console (VNC / serial). If no console, boot to rescue mode or mount the root filesystem from another machine, edit /etc/ssh/sshd_config, restore.
- Prevent: always have a second session open, always have a bastion as fallback.
Trap 2: Removed authorized_keys, permission now wrong.
- Fix: chmod 600 ~/.ssh/authorized_keys && chown user:user ~/.ssh/authorized_keys.
- Prevent: never edit authorized_keys while logged in as that user. Do it from another machine.
Trap 3: Changed port to 2222, forgot to update firewall.
- Fix: sudo iptables -A INPUT -p tcp --dport 2222 -j ACCEPT.
- Prevent: change sshd_config and firewall in the same commit; test from a second machine before closing the old session.
Trap 4: AllowUsers typo blocks legit users.
- Fix: sudo vim /etc/ssh/sshd_config, sudo systemctl reload sshd.
- Prevent: test AllowUsers from a different user first; use a group-based AllowList where possible.
Trap 5: Disabled PAM but sudo still needs it.
- Fix: flip UsePAM yes back on, keep PasswordAuthentication no.
- Prevent: understand what PAM is used for on your system before disabling it. sudo -l still needs PAM.
Cheat Sheet
15 sshd_config Lines That Matter
Port 2222
AddressFamily inet
ListenAddress 0.0.0.0
Protocol 2
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitEmptyPasswords no
PermitRootLogin no
MaxAuthTries 3
LoginGraceTime 30
AllowUsers alice@10.0.0.0/24 bob@192.168.1.0/24
ClientAliveInterval 300
ClientAliveCountMax 2
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
LogLevel VERBOSE
Subsystem sftp internal-sftp
12 Diagnostic Commands
| # | Command | What it tells you |
|---|---|---|
| 1 | sshd -t |
Config syntax check |
| 2 | sshd -T \| grep -Ei 'port\|auth\|allow' |
Effective config (all merged) |
| 3 | sudo systemctl status sshd |
Service state |
| 4 | sudo journalctl -u ssh -n 50 |
Recent auth attempts |
| 5 | sudo fail2ban-client status |
Jails and ban counts |
| 6 | sudo fail2ban-client get sshd banned |
Currently banned IPs |
| 7 | sudo fail2ban-client unban IP |
Manual unban |
| 8 | grep -i auth /var/log/auth.log \| tail -50 |
Raw auth log (Debian) |
| 9 | journalctl -u ssh -f |
Live auth log |
| 10 | nmap -sV -p 2222 target |
Verify exposed services |
| 11 | ssh -v user@host |
Verbose handshake (debug auth failures) |
| 12 | ss -tn state established '( sport = :2222 )' |
Active SSH sessions |
3 Minimal Configs
Hardening base (5-layer minimum):
Port 2222
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
MaxAuthTries 3
LoginGraceTime 30
AllowUsers alice@10.0.0.0/24
LogLevel VERBOSE
Fail2ban aggressive:
[sshd]
enabled = true
port = 2222
backend = systemd
mode = aggressive
bantime = 1h
findtime = 10m
maxretry = 3
PAM with MFA:
Where This Doesn't Help
SSH hardening is a partial defense, not a comprehensive one. If an attacker has your private key, none of the above matters. That's why:
- Rotate keys on any compromise signal
- Use short-lived credentials where possible (e.g., short-lived
authorized_keysfrom a just-in-time system) - Monitor key fingerprints — a new key appearing is a red flag
- Consider bastion hosts — the bastion becomes the only SSH-exposed endpoint, and your internal servers have
sshdbound to127.0.0.1only
Related Articles
- Bastion Jumpserver — concentrate SSH on a bastion, remove it from servers
- Prometheus Alerts — alert on
fail2banbans, auth failures - Prometheus Deploy — metrics back the Fail2ban dashboards
- Nginx Reverse Proxy — Nginx in front can add another layer
Authoritative References
- OpenSSH Security Wiki — canonical OpenSSH security guidance
- man7.org:
sshd_config(5)— every directive documented - GitHub: fail2ban/fail2ban — jail filters and defaults
- man7.org:
ssh(1)— client options (agent forwarding, MFA) - RFC 4251 — SSH Protocol Architecture — wire-level spec
Written for ops.jiunotes.top. Original work based on production hardening experience. Not copied from any third-party blog. CC BY-NC-SA 4.0.