SSH is the front door to every Linux server. It is also the most commonly brute-forced service on the internet. Despite this, most production SSH configurations haven’t changed meaningfully in a decade: password authentication disabled (maybe), an RSA key pair generated once and copied everywhere, and fail2ban running with default settings.

That is not hardening. That is the bare minimum from 2015.

In this article, we’ll build a modern SSH security posture layer by layer, starting from basic brute-force protection and ending with certificate-based authentication that eliminates static keys entirely.

Layer 1: fail2ban — Blocking Brute-Force Attacks

fail2ban monitors log files for repeated authentication failures and dynamically creates firewall rules to ban offending IPs. It’s the simplest defense against automated SSH brute-force attacks.

Installation and Configuration

1
2
3
4
5
# Debian/Ubuntu
sudo apt install fail2ban

# RHEL/Fedora
sudo dnf install fail2ban

Create a local configuration file (never edit the defaults directly — they get overwritten on updates):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 3
banaction = nftables-multiport
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8

[sshd]
enabled  = true
port     = ssh
filter   = sshd
logpath  = /var/log/auth.log
maxretry = 3
bantime  = 24h
findtime = 10m

Key parameters:

ParameterPurposeRecommended Value
maxretryFailed attempts before ban3
bantimeHow long the IP stays banned24h for SSH
findtimeWindow to accumulate failures10m
ignoreipIPs that are never bannedYour management subnet
banactionFirewall backendnftables-multiport

Enable and start the service:

1
2
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
fail2ban is reactive, not preventive. It only acts after failed login attempts have already occurred. An attacker with valid credentials or a zero-day in sshd will not trigger fail2ban. Treat it as the outermost layer, not your primary defense.

Layer 2: IP Allowlisting

If you know which IPs need SSH access, restrict it at both the sshd and firewall level.

sshd-Level Restriction

The AllowUsers and Match directives in sshd_config can restrict access by source IP:

1
2
# /etc/ssh/sshd_config
AllowUsers deploy@10.0.1.* admin@10.0.1.50

This ensures that even if an attacker has valid credentials, they can only authenticate from the specified network.

Firewall-Level Restriction (nftables)

The firewall should be the primary enforcement point:

1
2
3
# Allow SSH only from management subnet
sudo nft add rule inet filter input ip saddr 10.0.1.0/24 tcp dport 22 accept
sudo nft add rule inet filter input tcp dport 22 drop

Or with ufw for simpler setups:

1
2
sudo ufw allow from 10.0.1.0/24 to any port 22 proto tcp
sudo ufw deny 22/tcp

Cloud Provider Security Groups

On AWS, GCP, or Azure, the security group / firewall rule should be your first line of defense. Never leave port 22 open to 0.0.0.0/0 in a security group — restrict it to your VPN egress IPs or bastion host.

Layer 3: Modern Cryptographic Algorithms

The cryptographic defaults in older OpenSSH versions are dangerously permissive. A hardened configuration should explicitly specify which algorithms are acceptable.

Ed25519 Keys

Ed25519 is the recommended key type in 2026. It’s based on Curve25519, produces compact 256-bit keys, is faster to generate and verify than RSA, and offers fewer opportunities for side-channel attacks:

1
ssh-keygen -t ed25519 -C "admin@infra-2026" -f ~/.ssh/id_ed25519

Stop generating RSA keys. If you need backward compatibility with ancient systems, use RSA-4096 as a fallback — never RSA-2048.

Post-Quantum Key Exchange

OpenSSH 9.x defaults to sntrup761x25519-sha512@openssh.com as the key exchange algorithm. This is a hybrid that combines X25519 (classical elliptic curve) with NTRU Prime (post-quantum lattice-based). If either algorithm is broken, the other still provides security:

# Verify your default KEX on a connection
ssh -vv server.example.com 2>&1 | grep "kex: algorithm:"
# Expected: sntrup761x25519-sha512@openssh.com
Why post-quantum now? The threat model is “harvest now, decrypt later.” Adversaries record encrypted SSH sessions today, hoping to decrypt them with a future quantum computer. The hybrid key exchange protects against this without sacrificing classical security.

Disabling Weak Algorithms

Explicitly restrict ciphers, MACs, and key exchange algorithms in sshd_config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# /etc/ssh/sshd_config — Cryptographic hardening

# Key exchange: post-quantum hybrid + strong classical
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org

# Ciphers: AEAD only
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com

# MACs: only used for non-AEAD ciphers, but restrict anyway
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com

# Host keys: Ed25519 only (remove RSA host keys if not needed)
HostKey /etc/ssh/ssh_host_ed25519_key
Algorithm CategoryAllowedExplicitly Removed
Key Exchangesntrup761x25519-sha512, curve25519-sha256diffie-hellman-*, ecdh-sha2-*
Cipherschacha20-poly1305, aes256-gcm, aes128-gcmaes*-cbc, 3des-cbc, aes*-ctr
MACshmac-sha2-512-etm, hmac-sha2-256-etmhmac-sha1*, hmac-md5*, umac-*
Host KeysEd25519RSA, ECDSA, DSA

Layer 4: sshd_config Hardening

Beyond cryptographic settings, several sshd options directly reduce attack surface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# /etc/ssh/sshd_config — Access control hardening

# Disable root login entirely
PermitRootLogin no

# Disable password authentication — keys/certificates only
PasswordAuthentication no
KbdInteractiveAuthentication no

# Limit authentication attempts per connection
MaxAuthTries 3

# Reduce the login grace period (time to authenticate before disconnect)
LoginGraceTime 20

# Disconnect idle sessions
ClientAliveInterval 300
ClientAliveCountMax 2

# Disable X11 forwarding (unless explicitly needed)
X11Forwarding no

# Disable agent forwarding (use ProxyJump instead)
AllowAgentForwarding no

# Disable TCP forwarding unless needed
AllowTcpForwarding no

# Restrict to specific users/groups
AllowGroups ssh-users

# Log verbosely for audit trails
LogLevel VERBOSE

# Disable unused authentication methods
HostbasedAuthentication no
PermitEmptyPasswords no

# Use the sandboxed privilege separation (default in modern OpenSSH)
UsePAM yes

After any change, always validate the configuration before restarting:

1
sudo sshd -t && sudo systemctl restart sshd

Layer 5: SSH Certificate-Based Authentication

This is where modern SSH security diverges from the traditional approach. Instead of distributing public keys to every server’s authorized_keys file, you use an SSH Certificate Authority (CA) to sign short-lived certificates.

The Problem with authorized_keys

The traditional SSH key model has fundamental operational problems:

ProblemImpact
Keys are distributed to every serverRevoking a key means touching every authorized_keys file
Keys never expireA compromised key works forever until manually removed
No identity metadataThe server sees a key, not a person — no audit trail
Key sprawlEngineers accumulate keys across dozens of servers with no inventory

How SSH Certificates Work

An SSH CA signs a user’s public key, producing a certificate with embedded metadata: identity, validity period, allowed principals, and extensions. The server trusts the CA, not individual keys.

┌──────────┐     ┌──────────────┐     ┌──────────────┐
│  Engineer │────▶│  Vault (CA)  │────▶│   SSH Server  │
│           │     │              │     │               │
│ 1. Auth   │     │ 2. Sign key  │     │ 3. Verify     │
│ via OIDC  │     │    (60min    │     │    cert vs    │
│           │     │     TTL)     │     │    trusted CA │
└──────────┘     └──────────────┘     └──────────────┘

Using HashiCorp Vault as SSH CA

Vault’s SSH secrets engine acts as a certificate authority, signing user keys on demand with configurable TTLs.

1. Enable the SSH secrets engine in Vault:

1
vault secrets enable -path=ssh-client-signer ssh

2. Generate or configure the CA key pair:

1
vault write ssh-client-signer/config/ca generate_signing_key=true

3. Retrieve the CA public key and deploy it to servers:

1
vault read -field=public_key ssh-client-signer/config/ca > /etc/ssh/trusted-user-ca-keys.pem

4. Configure sshd to trust the CA:

1
2
# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem

5. Create a Vault role defining certificate parameters:

1
2
3
4
5
6
7
8
vault write ssh-client-signer/roles/engineer \
    key_type=ca \
    default_user=deploy \
    allowed_users="deploy,admin" \
    allow_user_certificates=true \
    ttl=1h \
    max_ttl=4h \
    default_extensions='{"permit-pty": "", "permit-agent-forwarding": ""}'

6. Engineers sign their key and connect:

1
2
3
4
5
6
7
8
9
# Authenticate to Vault (OIDC, LDAP, etc.)
vault login -method=oidc

# Sign the public key — certificate is valid for 1 hour
vault write -field=signed_key ssh-client-signer/sign/engineer \
    public_key=@$HOME/.ssh/id_ed25519.pub > ~/.ssh/id_ed25519-cert.pub

# Connect — the server validates the certificate against the CA
ssh deploy@server.example.com

The certificate expires in 1 hour. No authorized_keys file is touched. The server’s auth log shows the certificate’s identity metadata, not just a key fingerprint.

Certificate benefits in practice: onboarding a new engineer means granting them Vault access — not copying keys to 50 servers. Offboarding means revoking Vault access — their certificates expire naturally. No key rotation campaigns, no stale keys, no “who has access to production?” mystery.

Layer 6: VPN-Gated SSH Access

The most effective way to reduce SSH attack surface is to remove it from the public internet entirely. Place SSH behind a VPN so that only authenticated VPN users can even reach port 22.

Architecture with Pritunl or WireGuard

┌──────────┐     ┌──────────────┐     ┌──────────────┐
│  Engineer │────▶│  VPN Gateway │────▶│  SSH Server   │
│           │     │  (Pritunl /  │     │  (port 22     │
│ WireGuard │     │  WireGuard)  │     │  bound to     │
│ tunnel    │     │              │     │  10.8.0.0/24) │
└──────────┘     └──────────────┘     └──────────────┘

Configure sshd to only listen on the VPN interface:

1
2
# /etc/ssh/sshd_config
ListenAddress 10.8.0.1

With this setup, port 22 is unreachable from the public internet. Port scans see nothing. Brute-force attempts are structurally impossible because the TCP connection never completes. fail2ban becomes a safety net rather than a primary defense.

Pritunl supports integration with OIDC providers (including Authentik), so VPN access can be gated behind the same identity layer as your other services — with MFA, group policies, and centralized audit logging.

Layer 7: Port Knocking

Port knocking adds a layer of obscurity by keeping port 22 completely closed until a specific sequence of connection attempts is made to other ports. It’s not a security mechanism in isolation, but it eliminates noise from automated scanners.

Setup with knockd

1
sudo apt install knockd
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# /etc/knockd.conf
[options]
    UseSyslog

[openSSH]
    sequence    = 7000,8000,9000
    seq_timeout = 5
    command     = /usr/sbin/nft add rule inet filter input ip saddr %IP% tcp dport 22 accept
    tcpflags    = syn

[closeSSH]
    sequence    = 9000,8000,7000
    seq_timeout = 5
    command     = /usr/sbin/nft delete rule inet filter input handle $(nft -a list chain inet filter input | grep %IP% | grep "dport 22" | awk '{print $NF}')
    tcpflags    = syn

Client-side usage:

1
2
3
4
5
6
7
8
# Knock to open
knock server.example.com 7000 8000 9000

# Connect
ssh deploy@server.example.com

# Knock to close (or let it timeout)
knock server.example.com 9000 8000 7000
Port knocking is obscurity, not security. An attacker sniffing network traffic can observe the knock sequence. Use it as an additional layer to reduce log noise and automated scanning — never as a primary access control mechanism.

Complete Hardened sshd_config

Here’s a practical production configuration combining all the layers discussed above:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# /etc/ssh/sshd_config — Hardened configuration (2026)
# Tested on OpenSSH 9.x / Ubuntu 24.04+

# ── Network ──────────────────────────────────────────
Port 22
ListenAddress 10.8.0.1              # VPN interface only
AddressFamily inet

# ── Authentication ───────────────────────────────────
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 20
PermitEmptyPasswords no
HostbasedAuthentication no

# ── Certificate-Based Auth (Vault CA) ───────────────
TrustedUserCAKeys /etc/ssh/trusted-user-ca-keys.pem
# AuthorizedKeysFile none          # Uncomment to fully disable key-based auth

# ── Access Control ───────────────────────────────────
AllowGroups ssh-users
# AllowUsers deploy@10.8.0.* admin@10.8.0.50

# ── Cryptography ─────────────────────────────────────
HostKey /etc/ssh/ssh_host_ed25519_key

KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org
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

# ── Session ──────────────────────────────────────────
ClientAliveInterval 300
ClientAliveCountMax 2
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no

# ── Logging ──────────────────────────────────────────
LogLevel VERBOSE
UsePAM yes

# ── Misc ─────────────────────────────────────────────
PrintMotd no
AcceptEnv LANG LC_*
Subsystem sftp /usr/lib/openssh/sftp-server

After deploying, validate and restart:

1
sudo sshd -t && sudo systemctl restart sshd

Layered Defense Summary

Each layer addresses a different threat vector. No single layer is sufficient on its own:

LayerProtects AgainstLimitation
fail2banBrute-force attacksReactive; doesn’t help with valid credentials
IP allowlistingUnauthorized source IPsDoesn’t help if attacker is on allowed network
Modern algorithmsCryptographic attacks, quantum harvestRequires client compatibility
sshd hardeningMisconfiguration exploitationDoesn’t prevent authorized user abuse
SSH certificates (Vault)Key sprawl, stale access, missing audit trailRequires Vault infrastructure
VPN-gated accessPublic internet exposureVPN becomes single point of entry
Port knockingAutomated scanning, log noiseObscurity, not security

The goal is to ensure that an attacker must defeat multiple independent layers to gain SSH access. Compromising one layer — a stolen VPN credential, a misconfigured firewall rule, a leaked certificate — should not be sufficient on its own.

Conclusion

SSH hardening in 2026 is not about picking the one right control. It’s about layering defenses so that each compensates for the weaknesses of the others. fail2ban handles brute-force noise. Firewall rules and VPN access eliminate public exposure. Modern cryptographic algorithms protect the channel. Certificate-based authentication via Vault eliminates the operational nightmare of static key management.

If you’re still distributing authorized_keys files and calling it secure, you’re a decade behind. Move to certificate-based authentication. Put SSH behind a VPN. Harden the daemon configuration. Treat every layer as necessary and none as sufficient.


References: