Every SSH hardening guide on the internet covers the same ground: disable root login, switch to key-based authentication, change the default port. These are fine starting points, but they are starting points. If you are running production infrastructure -- servers that handle real traffic, store real data, and face real adversaries -- you need to go further. This article covers the configurations and architectures that actually reduce your attack surface in meaningful ways: post-quantum cryptographic hardening to future-proof key exchange, certificate-based authentication to eliminate key sprawl, FIDO2 hardware security keys for high-value accounts, jump hosts with ProxyJump to enforce network segmentation, port knocking to hide services from scanners, fail2ban tuning for intelligent brute-force mitigation, and PerSourcePenalties for granular abuse mitigation.

We assume you already have password authentication disabled and are using Ed25519 or RSA 4096-bit keys. If not, go handle that first, then come back. What follows builds on that foundation.

Cryptographic Hardening: Algorithms Matter

Before we get into architecture, let's harden the cryptographic primitives your SSH daemon actually uses. Out of the box, OpenSSH enables a broad set of key exchange algorithms, ciphers, and MACs for maximum compatibility. That compatibility comes at a cost -- many of those algorithms are outdated, weak, or both. The ssh-audit hardening guides provide the definitive reference here, and their recommendations have been adopted by organizations including Mozilla in their OpenSSH security guidelines.

The first step is regenerating your host keys. Remove the default keys and generate fresh Ed25519 and RSA 4096-bit host keys. Ed25519 is the current gold standard for SSH host keys -- it offers strong security with compact key sizes and fast signature verification.

regenerate host keys
# Remove existing host keys
$ sudo rm /etc/ssh/ssh_host_*

# Generate new Ed25519 and RSA 4096 host keys
$ sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
$ sudo ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ""

# Remove weak Diffie-Hellman moduli (below 3072 bits)
$ awk '$5 >= 3071' /etc/ssh/moduli > /etc/ssh/moduli.safe
$ sudo mv /etc/ssh/moduli.safe /etc/ssh/moduli

That last step is critical. The /etc/ssh/moduli file contains pre-computed Diffie-Hellman group exchange parameters, and many distributions ship with 1024-bit and 2048-bit groups that are computationally vulnerable to well-resourced adversaries. Filtering to 3072-bit minimum aligns with NIST recommendations for 128-bit equivalent security strength.

Next, create a drop-in configuration file to restrict the algorithms your server will negotiate. This is the approach recommended by Ubuntu's official OpenSSH crypto documentation -- use /etc/ssh/sshd_config.d/ for modular configuration rather than editing the main file directly.

/etc/ssh/sshd_config.d/crypto-hardening.conf
# Host key preference order -- Ed25519 first for performance
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key

# Key exchange algorithms -- post-quantum hybrids first (mlkem768 for OpenSSH 10.x, sntrup761 for 9.x fallback)
KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group-exchange-sha256

# Ciphers -- AEAD modes preferred, larger key sizes first
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr

# MACs -- Encrypt-then-MAC only
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,umac-128-etm@openssh.com

# Host key algorithms
HostKeyAlgorithms ssh-ed25519-cert-v01@openssh.com,ssh-ed25519,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256

# Minimum RSA key size
RequiredRSASize 3072
Note

The mlkem768x25519-sha256 key exchange algorithm is a post-quantum hybrid that combines ML-KEM (a NIST-standardized lattice-based key encapsulation mechanism) with the classical X25519 exchange. It was added in OpenSSH 9.9 and became the default key exchange in OpenSSH 10.0 (April 2025). The older sntrup761x25519-sha512 hybrid, available since OpenSSH 9.0, combines Streamlined NTRU Prime with X25519. Both provide forward protection against future quantum attacks. As of OpenSSH 10.1, the client will actively warn you when a connection does not use a post-quantum key exchange. We list both algorithms here for maximum compatibility across OpenSSH 9.x and 10.x deployments.

Warning

If you have clients or servers running OpenSSH versions older than 9.6, be aware of the Terrapin attack (CVE-2023-48795), which exploits a flaw in SSH's binary packet protocol to manipulate sequence numbers during the handshake. The chacha20-poly1305@openssh.com cipher and any cipher combined with *-etm@openssh.com MACs are affected. OpenSSH 9.6+ includes strict key exchange countermeasures (kex-strict-s-v00@openssh.com) that mitigate this attack automatically. If you must support older clients, consider temporarily removing chacha20-poly1305@openssh.com from your cipher list until they can be upgraded. Run ssh-audit to check whether your server and clients negotiate the strict key exchange extension.

After applying these changes, validate the configuration and verify it's active before restarting:

validate and apply
# Verify configuration syntax before restarting
$ sudo sshd -t

# Check effective cipher configuration
$ sudo sshd -T | grep -E "ciphers|macs|kexalgorithms"

# Reload (not restart) to apply without dropping connections
$ sudo systemctl reload ssh

# Audit the results
$ ssh-audit localhost
Caution

Always run sshd -t before restarting the SSH daemon. A syntax error in your configuration will prevent sshd from starting, and if you're connected remotely, you will lock yourself out. Keep a second SSH session open as a safety net, or ensure you have out-of-band access (IPMI, cloud console, KVM) before applying changes.

The Production sshd_config

Beyond cryptographic primitives, there are several sshd_config directives that meaningfully improve your security posture. Here is a hardened configuration block that goes well beyond disabling root login. These settings are drawn from the Mozilla OpenSSH guidelines and the nixCraft OpenSSH best practices reference.

/etc/ssh/sshd_config.d/hardening.conf
# ── Authentication ──
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
AuthenticationMethods publickey
PubkeyAuthentication yes
MaxAuthTries 3
MaxSessions 3
LoginGraceTime 20

# ── Logging ──
LogLevel VERBOSE
SyslogFacility AUTH

# ── Network ──
AddressFamily any
AllowAgentForwarding no
AllowTcpForwarding no
AllowStreamLocalForwarding no
X11Forwarding no
PermitTunnel no
GatewayPorts no
PermitUserEnvironment no

# ── Restrict to specific users/groups ──
AllowGroups ssh-users

# ── Banners and info leakage ──
DebianBanner no
Banner /etc/ssh/banner.txt
PrintLastLog yes

# ── Connection throttling (DHEat mitigation) ──
PerSourceMaxStartups 1
MaxStartups 10:30:60

# ── Penalty-based abuse mitigation (OpenSSH 9.8+) ──
PerSourcePenalties crash:90 authfail:5 noauth:1 grace-exceeded:20 max:600

A few of these deserve explanation. LogLevel VERBOSE is essential for auditing -- it logs the key fingerprint used for each login, giving you a clear trail of which key authenticated which session. The Mozilla guidelines specifically call out that this level of logging is necessary to maintain accountability in multi-user environments. LoginGraceTime 20 gives users 20 seconds to complete authentication before the connection is dropped -- enough for a legitimate user with a key, too short for many automated attacks. MaxStartups 10:30:60 implements connection rate limiting: after 10 unauthenticated connections, new connections have a 30% chance of being dropped, increasing linearly until 100% at 60 connections. The PerSourceMaxStartups directive, added in OpenSSH 8.5, limits each source IP to a single unauthenticated connection at a time -- a direct countermeasure to the DHEat denial-of-service attack documented by the ssh-audit project.

The PerSourcePenalties directive, introduced in OpenSSH 9.8, is a newer and more granular mechanism. Rather than simply limiting connections, it applies time-based penalties to source IPs based on specific misbehaviors: 90 seconds for crashes, 5 seconds for authentication failures, 1 second for connections that never authenticate, and 20 seconds for exceeding the grace period. The max:600 caps the accumulated penalty at 10 minutes. During the penalty period, all connections from that source are refused. Use PerSourcePenaltyExemptList to whitelist your management IPs and monitoring systems -- health checks that connect without authenticating will otherwise accumulate noauth penalties. This is particularly effective against the regreSSHion vulnerability (CVE-2024-6387), where an attacker needs thousands of connection attempts over hours -- the penalty system makes such sustained attacks impractical.

Note

We set AddressFamily any above, which accepts both IPv4 and IPv6 connections. If your infrastructure is IPv4-only and you want to eliminate the IPv6 attack surface entirely, change this to AddressFamily inet. Conversely, if you need IPv6 only, use inet6. The key point is to make this a deliberate decision based on your network topology rather than leaving it at the default. Similarly, AllowStreamLocalForwarding no disables Unix domain socket forwarding -- a less well-known forwarding vector that should be locked down alongside TCP and agent forwarding in hardened environments.

Pro Tip

Create an ssh-users group and add only the accounts that actually need SSH access: sudo groupadd ssh-users && sudo usermod -aG ssh-users deploy. The AllowGroups directive then becomes a whitelist -- even if an attacker compromises a local account, they cannot SSH in unless that account is in the group.

Match Blocks: Conditional Configuration

Production environments rarely have uniform access requirements. The Match directive lets you apply different settings to different users, groups, or source networks. This is how you grant CI/CD pipelines the TCP forwarding they need without opening it for everyone:

/etc/ssh/sshd_config.d/match-rules.conf
# Default: no forwarding for anyone
AllowTcpForwarding no
AllowAgentForwarding no

# CI/CD service accounts need TCP forwarding for tunnels
Match Group ci-deploy
    AllowTcpForwarding yes
    PermitOpen 10.0.2.20:5432 10.0.2.21:5432
    X11Forwarding no
    ForceCommand /usr/local/bin/deploy-wrapper.sh

# Restrict emergency access from specific management network
Match Address 10.250.0.0/24
    PasswordAuthentication yes
    AuthenticationMethods publickey,keyboard-interactive:pam
    MaxAuthTries 5

The key constraint to remember: Match blocks only support a subset of directives, and they apply to the end of the file or until the next Match block. Always run sshd -T -C user=deploy,host=10.0.1.5 to verify the effective configuration for a given connection. Not all directives can appear in Match blocks -- notably, MaxStartups and PerSourceMaxStartups cannot, so connection throttling is always global.

Hardware Security Keys (FIDO2/U2F)

For high-value access -- infrastructure admin accounts, root-equivalent users, break-glass credentials -- hardware security keys provide a level of assurance that software keys cannot match. Since OpenSSH 8.2, the sk-ssh-ed25519@openssh.com and sk-ecdsa-sha2-nistp256@openssh.com key types support FIDO2/U2F tokens like YubiKeys. The private key material never leaves the hardware token, which makes it immune to host compromise, malware exfiltration, and key copying.

FIDO2 key generation and usage
# Generate a FIDO2-backed Ed25519 key (requires physical touch of token)
$ ssh-keygen -t ed25519-sk -C "admin-yubikey"

# For resident keys (stored on the token, portable across machines)
$ ssh-keygen -t ed25519-sk -O resident -O verify-required -C "admin-yubikey-resident"

# Require touch verification on every authentication (-O verify-required)
# This prevents malware from using the key while the token is plugged in

# List resident keys stored on a connected FIDO2 token
$ ssh-keygen -K

The -O verify-required flag is critical for security: it ensures the token requires user verification (PIN, biometric, or touch) for each authentication event, not just token presence. Without this, malware running on a compromised workstation could silently use the key whenever the token is plugged in. FIDO2 keys can be combined with SSH certificates -- sign the sk-ssh-ed25519 public key with your User CA just like any other key, gaining both hardware-backed authentication and centralized certificate management.

Note

As of OpenSSH 10.0, DSA support has been entirely removed from the codebase. If you encounter legacy systems still using DSA host keys, they will need to be rekeyed with Ed25519 or RSA before upgrading OpenSSH. This is a one-time migration but worth planning for in environments with older infrastructure.

Certificate-Based Authentication

Standard public key authentication works fine at small scale, but it has a fundamental problem: there is no built-in expiration, no revocation mechanism beyond manually editing authorized_keys on every server, and no identity metadata tied to the key. As the OpenSSH Wikibooks documentation explains, SSH certificates address these issues by bundling a public key with identity information, validity periods, and principal restrictions -- all signed by a trusted Certificate Authority.

The architecture has two components: a User CA that signs user certificates, and a Host CA that signs host certificates. Keeping them separate allows independent rotation and limits blast radius if either is compromised. Here is how to set up the full chain.

Step 1: Create the Certificate Authorities

create CA keys (on a secure, offline machine)
# Generate the User CA -- signs user certificates
$ ssh-keygen -t ed25519 -f user-ca -C "USER-CA" -N "strong-passphrase"

# Generate the Host CA -- signs host certificates
$ ssh-keygen -t ed25519 -f host-ca -C "HOST-CA" -N "strong-passphrase"

# Result: four files
# user-ca (private), user-ca.pub (public)
# host-ca (private), host-ca.pub (public)
Caution

The CA private keys are the crown jewels. Anyone who possesses them can mint valid certificates for any user or host. Generate these on an air-gapped machine, encrypt them with a strong passphrase, and store them in a hardware security module (HSM) or encrypted vault in production. Never leave them on a networked server.

Step 2: Sign Host Keys

Host certificates eliminate trust-on-first-use (TOFU) -- that moment where SSH asks you to accept an unknown host fingerprint. Instead, your client trusts the Host CA and automatically validates any server presenting a certificate signed by that CA.

sign host keys
# Sign the server's Ed25519 host key with the Host CA
$ ssh-keygen -s host-ca \
    -I "web01.prod.example.com" \
    -h \
    -n "web01.prod.example.com,web01,192.168.1.10" \
    -V +52w \
    /etc/ssh/ssh_host_ed25519_key.pub

# -s: signing key (Host CA private key)
# -I: certificate identity (for logging)
# -h: this is a HOST certificate
# -n: principals (hostnames/IPs the cert is valid for)
# -V: validity period (52 weeks from now)

Then configure the server to present this certificate and configure clients to trust the CA:

server: /etc/ssh/sshd_config.d/certificates.conf
# Present the signed host certificate to clients
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub

# Trust user certificates signed by the User CA
TrustedUserCAKeys /etc/ssh/user-ca.pub
client: ~/.ssh/known_hosts (add this line)
# Trust any host presenting a certificate signed by our Host CA
@cert-authority *.prod.example.com ssh-ed25519 AAAA...host-ca-public-key...

Step 3: Sign User Keys

This is where certificates truly shine. Instead of distributing public keys to every server's authorized_keys file, you sign a user's existing public key with the User CA, embedding their identity, allowed principals (usernames), and an expiration time.

sign a user key
# Sign a user's public key -- valid for 8 hours
$ ssh-keygen -s user-ca \
    -I "alice@example.com" \
    -n "deploy,alice" \
    -V +8h \
    -O no-agent-forwarding \
    -O no-port-forwarding \
    -O no-pty \
    -O source-address=10.0.0.0/8 \
    alice_ed25519.pub

# Verify the certificate contents
$ ssh-keygen -L -f alice_ed25519-cert.pub

The certificate produced here is valid for only 8 hours, restricts the source IP range, disables agent forwarding and port forwarding, and limits which usernames the certificate can authenticate as. When Alice's workday ends, the certificate expires automatically -- no cleanup required on any server. When Alice leaves the organization, you simply stop issuing new certificates. This approach fundamentally ties access to identity with built-in expiration, rather than relying on static keys that persist indefinitely.

Revocation and Principal Restrictions

Short-lived certificates reduce revocation pressure, but you still need a mechanism for immediate revocation -- for example, when a key is compromised or an employee is terminated with a still-valid certificate. The RevokedKeys directive in sshd_config points to a Key Revocation List (KRL) that sshd checks on every authentication attempt:

certificate revocation
# Create an empty KRL
$ ssh-keygen -k -f /etc/ssh/revoked_keys

# Revoke a specific certificate by serial number
$ ssh-keygen -k -f /etc/ssh/revoked_keys -s user-ca -z 42 alice_ed25519.pub

# Revoke by key ID (useful when you don't have the public key file)
$ ssh-keygen -k -f /etc/ssh/revoked_keys -s user-ca alice_ed25519-cert.pub

# Add to sshd configuration
# RevokedKeys /etc/ssh/revoked_keys

On the server side, use AuthorizedPrincipalsFile to control which certificate principals (usernames) are accepted for each local account. Without this, any certificate signed by your User CA with a matching principal can authenticate to any server that trusts the CA. The principals file acts as a per-server allowlist:

server: /etc/ssh/sshd_config.d/certificates.conf (extended)
# Trust user certificates signed by the User CA
TrustedUserCAKeys /etc/ssh/user-ca.pub

# Restrict which principals can authenticate as each local user
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u

# Revoke compromised certificates immediately
RevokedKeys /etc/ssh/revoked_keys

Create a principals file for each local user. For example, /etc/ssh/auth_principals/deploy might contain deploy and sre-team, meaning only certificates issued with those principals can authenticate as the deploy user on this server. This gives you fine-grained, per-server access control on top of the CA trust model.

Pro Tip

For organizations not ready for a full CA infrastructure, AuthorizedKeysCommand offers a lighter-weight path to centralized key management. This directive tells sshd to run an external program to look up authorized keys dynamically -- for example, pulling them from LDAP, a secrets manager, or an internal API -- instead of reading static authorized_keys files. It eliminates manual key distribution without requiring certificate signing infrastructure.

Jump Hosts with ProxyJump

Network segmentation is one of the strongest controls you can implement, and jump hosts (also called bastion hosts) are how you enforce it for SSH. The idea is simple: your internal servers are not directly reachable from the internet. Instead, all SSH traffic routes through a hardened intermediary that sits in a DMZ. This single point of entry gives you centralized logging, tighter firewall rules, and a much smaller attack surface.

Before OpenSSH 7.3, configuring jump hosts required clunky ProxyCommand directives with netcat pipes. The ProxyJump directive, introduced in that release, makes it trivial -- and crucially, it maintains end-to-end encryption between your client and the final destination. The bastion host never sees the decrypted traffic.

~/.ssh/config
# ── Bastion host (publicly accessible) ──
Host bastion
    HostName bastion.prod.example.com
    User jump-user
    Port 22
    IdentityFile ~/.ssh/bastion_ed25519
    ForwardAgent no

# ── Internal servers behind the bastion ──
Host web01
    HostName 10.0.1.10
    User deploy
    IdentityFile ~/.ssh/internal_ed25519
    ProxyJump bastion

Host db01
    HostName 10.0.2.20
    User deploy
    IdentityFile ~/.ssh/internal_ed25519
    ProxyJump bastion

# ── Wildcard: all internal hosts jump through bastion ──
Host *.internal.example.com
    ProxyJump bastion
    User deploy

With this configuration, running ssh web01 transparently routes through the bastion. You can also chain multiple jump hosts for deep network segments:

$ ssh -J bastion1,bastion2 deep-internal-host
Warning

Never enable ForwardAgent on your bastion host. Agent forwarding exposes your local SSH agent socket on the remote machine -- if the bastion is compromised, an attacker can use your forwarded agent to authenticate to other servers. ProxyJump makes agent forwarding unnecessary because it creates a direct tunnel; your keys never leave your local machine.

Hardening the bastion host itself is equally critical. The Pinggy bastion host guide recommends: run only the SSH service (no other software), apply OS updates aggressively, enforce strict firewall rules that only allow inbound port 22, and log everything. The bastion should be a minimal, single-purpose machine with the smallest possible attack surface.

Port Knocking with knockd

Port knocking is a technique that hides your SSH port from network scanners entirely. The SSH port remains closed by default -- blocked at the firewall level. To open it, a client must send a specific sequence of connection attempts to predefined closed ports. Only after the correct "knock" sequence is received does the firewall temporarily open SSH access for that specific client IP.

It is important to be honest about what port knocking is and is not. It is not a substitute for real authentication -- it is security through obscurity, and should be treated as one layer in a defense-in-depth strategy. It is, however, remarkably effective at eliminating noise from automated scanners and reducing the volume of brute-force attempts to near zero.

install and configure knockd
# Install knockd
$ sudo apt install knockd

# Close SSH port in UFW before enabling knockd
$ sudo ufw status numbered
$ sudo ufw delete [rule-number-for-ssh]
/etc/knockd.conf
[options]
    UseSyslog
    logfile     = /var/log/knockd.log

[openSSH]
    sequence    = 39122,18734,52691
    seq_timeout = 5
    tcpflags    = syn
    command     = /sbin/iptables -I INPUT -s %IP% -p tcp --dport 22 -j ACCEPT

[closeSSH]
    sequence    = 52691,18734,39122
    seq_timeout = 5
    tcpflags    = syn
    command     = /sbin/iptables -D INPUT -s %IP% -p tcp --dport 22 -j ACCEPT

Several critical points about this configuration. First, change the default knock sequence. The values 7000, 8000, 9000 are the well-known knockd defaults and offer zero security. Use randomly generated port numbers -- the Arch Linux wiki recommends using a random number generator to select ports between 1 and 65535, then verifying you have not selected commonly scanned ports. Second, the seq_timeout of 5 seconds means the entire knock sequence must be completed within 5 seconds. Third, note that the firewall rule uses -I (insert at the top of the chain) rather than -A (append) -- order matters in iptables, and this ensures the allow rule is evaluated before any default deny rules.

Enable the knockd daemon:

enable knockd
# Edit /etc/default/knockd
# Set START_KNOCKD=1
# Set KNOCKD_OPTS="-i eth0" (use your actual interface)

$ sudo systemctl enable --now knockd
$ sudo systemctl status knockd

From a client machine, the workflow looks like this:

client-side knock and connect
# Send the knock sequence
$ knock -v server.example.com 39122 18734 52691 -d 500
hitting tcp server.example.com:39122
hitting tcp server.example.com:18734
hitting tcp server.example.com:52691

# Now SSH is open for your IP -- connect normally
$ ssh admin@server.example.com

# When done, close the port behind you
$ knock -v server.example.com 52691 18734 39122 -d 500
Warning

Port knocking does not play well with automated SSH access patterns (CI/CD pipelines, Ansible, cron-based backups). It adds operational complexity that may not be justified for every server. Use it on high-value targets where human-only access is expected, and pair it with certificate-based authentication and fail2ban for a comprehensive defense.

Note

Traditional port knocking sequences can be observed and replayed by an attacker who is monitoring network traffic. For a stronger alternative, consider Single Packet Authorization (SPA) using tools like fwknop. SPA achieves the same goal -- hiding SSH from scanners -- but authenticates the knock request cryptographically using HMAC and encryption within a single UDP packet. This eliminates the replay and sequence-sniffing risks inherent in traditional port knocking. The operational complexity is similar, but the security properties are significantly better.

Fail2Ban: Intelligent Brute-Force Mitigation

Fail2ban monitors log files for authentication failures and dynamically updates firewall rules to block offending IPs. It is the single most effective tool for eliminating brute-force noise on internet-facing SSH servers. According to the DigitalOcean Fail2ban tutorial, even organizations that use VPN-fronted SSH benefit from fail2ban as a secondary layer. The Arch Linux wiki cautions that fail2ban should not be considered a substitute for a VPN or for key-based authentication -- but it excels as one layer in a defense-in-depth strategy.

The default configuration is functional but conservative. Here is a production-tuned setup:

/etc/fail2ban/jail.d/sshd.local
[sshd]
enabled   = true
port      = ssh
filter    = sshd
backend   = systemd
maxretry  = 3
findtime  = 10m
bantime   = 1h
ignoreip  = 127.0.0.1/8 ::1 10.0.0.0/8

# Progressive ban escalation
bantime.increment   = true
bantime.factor      = 24
bantime.maxtime     = 4w
bantime.overalljails = true

Let's unpack the tuning decisions. maxretry = 3 bans after three failed attempts -- with key-based authentication, a legitimate user should never fail authentication, so three is generous. findtime = 10m defines the window; three failures within 10 minutes triggers a ban. bantime = 1h sets the initial ban duration, but the real power is in the progressive escalation: bantime.increment = true with a bantime.factor = 24 enables exponential growth using the formula ban.Time * (1<<ban.Count) * banFactor. The first ban uses the base bantime of 1 hour -- this deliberately avoids being too aggressive against legitimate users who make a few mistakes. When a previously banned IP re-offends, the increment formula kicks in with ban.Count starting at 0: first re-ban: 1h × 1 × 24 = 24 hours. Second re-ban: 1h × 2 × 24 = 48 hours. Third re-ban: 1h × 4 × 24 = 96 hours (4 days). Fourth re-ban: 1h × 8 × 24 = 192 hours (8 days). The bantime.maxtime = 4w caps it at four weeks. bantime.overalljails = true counts offenses across all jails, not just SSH. The net effect is that a persistent attacker gets a brief first ban, then hits exponentially longer bans on each subsequent offense, reaching the four-week maximum within a handful of re-offenses.

Note

On modern systemd-based distributions, set backend = systemd to read from the journal instead of log files. As multiple sources including the Arch Wiki note, the default backend = auto can select the wrong backend on some Debian-based systems, causing fail2ban to silently fail. It may also be necessary to set LogLevel VERBOSE in your sshd configuration so that password failures are logged in enough detail for fail2ban to detect them.

Monitor and manage your bans in real time:

fail2ban management commands
# Check overall status
$ sudo fail2ban-client status

# Check SSH jail specifically
$ sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 2
|  |- Total failed:     847
|  `- Journal matches:  _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
   |- Currently banned:  14
   |- Total banned:      203
   `- Banned IP list:    45.33.x.x 91.240.x.x ...

# Manually unban an IP (if you locked yourself out)
$ sudo fail2ban-client set sshd unbanip 192.168.1.100

# Manually ban a specific IP
$ sudo fail2ban-client set sshd banip 45.33.32.156
Caution

The Arch Wiki explicitly warns: if an attacker knows your IP address, they can send packets with a spoofed source header matching your IP and get you banned from your own server. Always add your own IP and management network ranges to ignoreip. For environments where this risk is unacceptable, combine fail2ban with a VPN like WireGuard that sits in front of SSH entirely.

Ongoing Auditing and Verification

Hardening is not a one-time event. Configuration drift, package updates, and operational changes can silently revert your settings. Build verification into your workflow with these tools and practices.

The ssh-audit tool is indispensable. It evaluates your server's algorithm support, identifies weak configurations, and produces a security score. Run it after every change and periodically as part of your compliance checks:

auditing workflow
# Install ssh-audit (choose one method)
$ sudo apt install ssh-audit              # Debian/Ubuntu
$ pipx install ssh-audit                  # pip (PEP 668-safe)
$ pip install ssh-audit --break-system-packages  # pip (legacy, not recommended)

# Audit a remote server
$ ssh-audit server.example.com

# Audit with policy check (returns non-zero on failure)
$ ssh-audit --policy hardened server.example.com

# Check effective sshd configuration
$ sudo sshd -T | grep -E "passwordauthentication|permitrootlogin|loglevel"
passwordauthentication no
permitrootlogin no
loglevel VERBOSE

# List supported algorithms on the running server
$ ssh -Q cipher && ssh -Q mac && ssh -Q kex

# Verify no authorized_keys files have appeared unexpectedly
$ find /home -name "authorized_keys" -exec ls -la {} \;

For fleet-wide verification, automate these checks with Ansible, Chef, or your configuration management tool of choice. A simple cron job that runs ssh-audit in policy mode and alerts on failure will catch regressions before they become vulnerabilities.

Warning

All user private keys should be protected with a strong passphrase. An unencrypted private key on a compromised workstation is equivalent to handing an attacker your credentials. Use ssh-keygen -p -f ~/.ssh/id_ed25519 to add a passphrase to an existing key. For team environments, audit this with: for f in ~/.ssh/id_*; do ssh-keygen -y -P "" -f "$f" &>/dev/null && echo "UNPROTECTED: $f"; done. Better yet, mandate FIDO2 hardware keys for admin accounts, where the private key cannot be extracted at all.

You should also monitor your SSH logs proactively. The VERBOSE log level we configured earlier records key fingerprints for every authentication event. Cross-reference these against your known key inventory to detect unauthorized key usage:

log analysis
# Find all successful authentications today
$ journalctl -u ssh --since today | grep "Accepted publickey"

# Find all failed authentication attempts
$ journalctl -u ssh --since today | grep "Failed"

# Check certificate validity for an upcoming expiration
$ ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub | grep Valid

Putting It All Together

No single technique here is a silver bullet. The power is in the combination. Certificate-based authentication eliminates key sprawl and provides built-in expiration, with KRL-based revocation for emergencies and principal restrictions for per-server access control. Cryptographic hardening strips out weak algorithms that have no place on a modern server, mitigates known attacks like Terrapin, and prepares your infrastructure for the post-quantum era. Jump hosts enforce network segmentation and centralize your audit trail. Port knocking -- or better yet, Single Packet Authorization -- hides your attack surface from scanners. Fail2ban dynamically blocks brute-force attempts, while PerSourcePenalties provides built-in abuse mitigation without external dependencies. Hardware security keys protect high-value credentials from host compromise. And ongoing auditing ensures none of this configuration drifts over time.

Start with the lowest-friction changes: cryptographic hardening and the production sshd_config are straightforward to deploy and immediately measurable with ssh-audit. Then layer in fail2ban and PerSourcePenalties. Graduate to certificate-based authentication with AuthorizedPrincipalsFile and RevokedKeys as your infrastructure scales. Add FIDO2 hardware keys for admin accounts. Port knocking and SPA are niche tools -- use them where they make sense, skip them where they don't.

The goal is not perfection. The goal is defense in depth: enough overlapping layers that no single failure is catastrophic. Every configuration in this article is verifiable, reversible, and grounded in the actual threat model facing production SSH infrastructure.