SSH is the most widely deployed remote administration protocol on the planet. It's also one of the most consistently misconfigured. The OpenSSH project ships sane defaults, but "sane" and "hardened" are not the same thing -- and the gap between them is exactly where attackers live. Whether you're preparing for a CIS benchmark audit, a PCI DSS assessment, or just trying to tighten up a fleet of servers before something goes wrong, this guide walks through every meaningful configuration decision, the reasoning behind it, and the tooling to verify your work.

Before changing anything, you need to know where you stand. The first tool to reach for is ssh-audit, an open-source scanner maintained by Joe Testa that connects to an SSH server and evaluates its advertised algorithms, key exchange methods, host keys, and banners against a known-good policy. It's the fastest way to get an objective picture of your current posture.

terminal
$ pip3 install ssh-audit
$ ssh-audit your.server.com

The output categorizes findings as good (green), warn (yellow), or fail (red). A freshly provisioned Ubuntu 24.04 server running OpenSSH 9.x will show a handful of warnings, primarily around the diffie-hellman-group14-sha256 key exchange and older RSA host key sizes. A CentOS 7 server with default settings will look considerably worse. The audit output becomes your checklist.

Understanding the Attack Surface

SSH hardening is not just about disabling password authentication -- that's table stakes, not a hardening posture. The real attack surface spans four distinct areas: the negotiation phase (what algorithms and ciphers the server will accept), the authentication phase (who can authenticate and how), the session phase (what authenticated users can do), and the operational posture (logging, access controls, and host key management). Each area has its own failure modes and its own mitigations.

Practitioners surveyed in the SANS ICS 2024 report on Linux server compromise patterns consistently found that SSH-related incidents traced back not to broken cryptography but to authentication failures -- stolen or unrotated private keys, absent MFA, and shared accounts -- and to misconfigured access controls that left authenticated sessions far too permissive. Cipher configuration rarely featured in the root cause.

That framing matters. Cryptographic hygiene is necessary but not sufficient. The sections below cover both, in the order a real audit would proceed.

ATT&CK Coverage in This Section

The four attack surface areas map directly to adversary techniques documented in the MITRE ATT&CK framework. Understanding which technique each control addresses helps prioritize hardening work and communicate risk to stakeholders. Technique references appear inline throughout this guide wherever a configuration decision directly mitigates a documented adversary behavior.

Why Version Currency Is a Hardening Control

Two vulnerabilities from 2024 and 2025 illustrate why keeping OpenSSH patched is not separate from hardening -- it is hardening. CVE-2024-6387, named "regreSSHion" and disclosed by the Qualys Threat Research Unit on July 1, 2024 (CVSS 8.1), is a signal handler race condition in sshd that allows unauthenticated remote code execution as root on glibc-based Linux systems. The flaw affected OpenSSH versions 8.5p1 through 9.7p1 in their default configurations -- no special settings required. Qualys estimated over 14 million potentially vulnerable instances exposed to the internet at the time of disclosure. The vulnerability is a regression: the original bug, CVE-2006-5051, was fixed in 2006; it was accidentally reintroduced with OpenSSH 8.5p1 in October 2020. The fix is in OpenSSH 9.8p1.

T1190  Exploit Public-Facing Application

regreSSHion is a textbook T1190 scenario: the service is internet-facing, exploitation requires zero prior authentication, and successful exploitation delivers root-level code execution -- the highest-privilege initial access path available. A perfectly hardened sshd_config on an unpatched binary provides no protection. Version currency is a hardening control, not an operational afterthought.

CVE-2025-26466, also discovered by Qualys TRU and disclosed February 18, 2025 (CVSS 5.9), is a pre-authentication denial-of-service affecting OpenSSH 9.5p1 through 9.9p1. No authentication or user interaction is required. The fix is in 9.9p2, with PerSourcePenalties as the configuration-level mitigation. Neither vulnerability required a misconfigured sshd -- the exposure came from running an unpatched binary.

The operational lesson is that cipher configuration and version currency address different parts of the threat model. A server running a perfectly hardened sshd_config on OpenSSH 8.6p1 was fully exposed to regreSSHion. Verify your installed version with sshd -V or ssh -V before treating any hardening checklist as complete.

Host Key Hygiene

Host keys are how clients verify they're talking to the right server. OpenSSH generates several key types by default on first install. The question is which ones you should advertise and which you should retire.

As of OpenSSH 10.x, the recommended host key types are Ed25519 and, for environments requiring RSA compatibility, RSA at 3072 bits or larger. Ed25519 is the clear preference for new deployments: it uses Curve25519, provides approximately 128 bits of classical security equivalent, generates compact 64-byte signatures, and has no known weaknesses in its construction. RSA at 3072 bits provides 128-bit security equivalent per NIST SP 800-57 Part 1 and is the minimum RSA key size worth advertising; RSA-2048 provides only 112 bits and falls under the NIST SP 800-131A Rev 3 IPD deprecation schedule after 2030. ECDSA (P-256 or P-384) remains acceptable but is less preferred than Ed25519 for new deployments given Ed25519's simpler, more auditable construction.

DSA host keys (ssh_host_dsa_key) must be removed. DSA was disabled by default in OpenSSH 7.0 (2015) and removed entirely from OpenSSH 10.0 (April 2025). If you find a DSA host key on a production server, it indicates either a system that has not been upgraded in a decade or a custom build. Either way, remove it.

terminal -- audit existing host keys
# List all host keys and their fingerprints
$ for f in /etc/ssh/ssh_host_*_key; do ssh-keygen -l -f "$f"; done

# Remove DSA host key if present
$ rm -f /etc/ssh/ssh_host_dsa_key /etc/ssh/ssh_host_dsa_key.pub

# Regenerate Ed25519 host key with explicit parameters
$ ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
Warning

Regenerating host keys will cause existing clients to receive a host key mismatch warning on next connection. Update ~/.ssh/known_hosts on affected client machines, or use a centralized known_hosts management tool. In automated pipelines this can break deploys if not handled proactively.

The sshd_config File: Directive by Directive

The OpenSSH server configuration lives at /etc/ssh/sshd_config. On modern distributions you'll also find /etc/ssh/sshd_config.d/ for drop-in overrides. The drop-in pattern is preferable for hardening changes because it survives package upgrades cleanly and is easier to audit.

What follows is a hardened configuration with every directive explained. This is not a copy-paste config -- it's an annotated reference so you understand what you're enabling and why.

Before You Touch Anything: Don't Lock Yourself Out

The single most reliable way to cause an incident while hardening SSH is to reload sshd from a terminal session that will be invalidated by the change you just made. Before editing sshd_config, open a second terminal session to the same host and leave it open. If the reload breaks authentication, that session stays alive and you can recover. A session already established is not affected by a reload -- only new connections are subject to the new configuration.

The reload workflow matters too. systemctl reload sshd sends SIGHUP to the daemon, which re-reads the configuration without terminating existing connections. systemctl restart sshd tears down the process entirely, which will terminate your active session on some distributions. Always use reload during interactive configuration work. Always run sshd -t first to catch syntax errors before they take effect.

terminal -- safe configuration reload workflow
# Step 1: validate syntax -- fix any errors before proceeding
$ sshd -t

# Step 2: reload (not restart) -- existing sessions survive
$ systemctl reload sshd

# Step 3: from your SECOND open terminal, verify a new connection still works
$ ssh -v [email protected]

# Only close your original session after confirming the new one authenticates
Recovery Option

If you do lose SSH access, your recovery path depends on the environment. On cloud instances (AWS EC2, GCP, Azure), use the console or serial port access to edit the config. On bare metal, you need physical access or out-of-band management (IPMI/iDRAC/iLO). On VMs, attach the disk to another instance. None of these are fast. Keep that second session open.

/etc/ssh/sshd_config.d/99-hardening.conf
# ── Protocol and Port ──────────────────────────────────────
# SSH protocol version 1 was deprecated years ago.
# OpenSSH 7.4+ refuses to compile it in. Verify only v2 runs.
Protocol 2

# Non-default port reduces automated scan noise.
# It is NOT a security control -- treat it as noise reduction only.
# Port 22 is fine if you use other controls; obscurity alone is not hardening.
Port 22

# ── Host Keys ──────────────────────────────────────────────
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_rsa_key
# Only advertise Ed25519 and RSA (3072+ bit). Remove ECDSA and DSA entries.

# ── Key Exchange Algorithms ────────────────────────────────
# mlkem768x25519-sha256 is the post-quantum hybrid KEX default since OpenSSH 10.0.
# sntrup761x25519 (the prior default since 8.5) is included for 9.x client compatibility.
# curve25519-sha256 is the fallback for clients that support neither PQ hybrid.
# diffie-hellman-group14-sha1 and diffie-hellman-group1-sha1 are removed.
KexAlgorithms [email protected],curve25519-sha256,[email protected],diffie-hellman-group16-sha512,diffie-hellman-group18-sha512

# ── Ciphers ────────────────────────────────────────────────
# AES-GCM and ChaCha20-Poly1305 provide authenticated encryption.
# CBC mode ciphers are vulnerable to BEAST and Lucky13 variants.
# 3DES-CBC, arcfour (RC4), and blowfish are completely off.
Ciphers [email protected],[email protected],[email protected],aes256-ctr,aes192-ctr,aes128-ctr

# ── Message Authentication Codes ──────────────────────────
# ETM (Encrypt-then-MAC) variants are strongly preferred.
# hmac-md5 and hmac-sha1 are removed. umac-64 has insufficient tag length.
MACs [email protected],[email protected],[email protected]

# ── Authentication ─────────────────────────────────────────
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
HostbasedAuthentication no
IgnoreRhosts yes
UsePAM yes

# ── Access Controls ────────────────────────────────────────
# Explicitly define which users or groups may connect.
# AllowUsers takes a space-separated list of usernames.
# AllowGroups is often cleaner for fleet management.
AllowGroups sshusers sudo

# ── Connection Hardening ───────────────────────────────────
LoginGraceTime 30
MaxAuthTries 3
MaxSessions 4
MaxStartups 10:30:60
# PerSourcePenalties (OpenSSH 9.8+) imposes per-source-IP connection
# penalties for authentication failures, crash events, and grace-time
# exhaustion. More targeted than MaxStartups for distributed attacks.
PerSourcePenalties yes
ClientAliveInterval 300
ClientAliveCountMax 2
TCPKeepAlive no

# ── File Permission Enforcement ────────────────────────────
# StrictModes instructs sshd to refuse login if key files have
# world-readable or world-writable permissions. Default is yes;
# setting it explicitly documents the intent and survives config drift.
StrictModes yes

# ── Compression ────────────────────────────────────────────
# Compression yes allows a pre-auth attacker to exploit oracle attacks
# on compressed traffic (analogous to CRIME/BREACH in TLS).
# "delayed" (compress after auth) is the historical middle ground,
# but "no" is the correct hardened choice on high-bandwidth links
# where you're not CPU-constrained. DISA STIG explicitly disallows
# pre-auth compression.
Compression no

# ── Disable Unused Features ────────────────────────────────
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
PermitTunnel no
GatewayPorts no
PermitUserEnvironment no

# ── SFTP ───────────────────────────────────────────────────
# If SFTP is required, use internal-sftp with chroot.
# If SFTP is not required, disable the subsystem entirely.
Subsystem sftp internal-sftp

# ── Logging ────────────────────────────────────────────────
SyslogFacility AUTH
LogLevel VERBOSE

# ── Banner ─────────────────────────────────────────────────
Banner /etc/ssh/banner

Understanding MaxStartups

The MaxStartups directive deserves its own explanation because its syntax is not intuitive. The format is start:rate:full. With 10:30:60: up to 10 unauthenticated connections are allowed unconditionally; from 10 to 60, each new connection has a 30% chance of being dropped; once 60 unauthenticated connections exist, all new ones are refused. This directly mitigates connection-exhaustion attacks and automated credential-stuffing tools that open many parallel connections.

TCP Keepalive vs. ClientAlive

These two mechanisms are frequently confused. TCPKeepAlive yes sends TCP-level keepalive packets handled by the kernel -- they work through NAT but are not encrypted and can be spoofed. ClientAliveInterval sends SSH-protocol-level keepalive messages through the encrypted channel. Setting TCPKeepAlive no and relying on ClientAliveInterval 300 with ClientAliveCountMax 2 is the more secure pattern: idle sessions are terminated after approximately 10 minutes of silence, and the keepalive cannot be trivially faked.

PerSourcePenalties: Per-IP Connection Throttling

Introduced in OpenSSH 9.8, PerSourcePenalties applies connection penalties at the per-source-IP level rather than globally. Where MaxStartups acts on the aggregate count of unauthenticated connections across all sources, PerSourcePenalties tracks authentication failures, crashes during the pre-auth phase, and grace-time exhaustion per originating address and imposes increasing wait times on offending IPs.

This directive was the recommended mitigation for CVE-2025-26466, disclosed by the Qualys Threat Research Unit on February 18, 2025 (CVSS 5.9, CWE-400). The vulnerability affected OpenSSH 9.5p1 through 9.9p1. The mechanism is precise: for every 16-byte SSH2_MSG_PING packet a server receives during key exchange, a 256-byte pong buffer is allocated and queued but never freed until the key exchange completes. An attacker who continuously sends ping packets before key exchange finishes can drive unbounded memory and CPU consumption on the server with no authentication required. This is a direct instance of T1499.002 Endpoint Denial of Service: Service Exhaustion Flood -- no credentials required, no payload to detect, just protocol-legal packets sent at volume. The fix is in 9.9p2; PerSourcePenalties is the configuration-level mitigation for unpatched systems. The two controls remain complementary on patched systems as well: MaxStartups limits flood volume globally; PerSourcePenalties targets persistent per-source behavior. Both belong in a hardened configuration.

PerSourcePenalties accepts a detailed configuration string that most guides omit entirely. The default behavior (just PerSourcePenalties yes) is reasonable, but tuning it explicitly makes the policy auditable and lets you adjust the penalty windows for your environment.

/etc/ssh/sshd_config -- PerSourcePenalties tuning
# Full syntax: crash=<penalty>:authfail=<penalty>:noauth=<penalty>:max=<max>:min=<min>
# crash      -- penalty (seconds) when the pre-auth process crashes (possible exploit attempt)
# authfail   -- penalty for each authentication failure
# noauth     -- penalty for a connection that disconnects without attempting auth (scanner behavior)
# max        -- maximum total penalty before the source IP is blocked entirely
# min        -- minimum penalty window; source is blocked for at least this long
PerSourcePenalties crash=60:authfail=5:noauth=2:max=600:min=15

# PerSourceNetBlockSize controls penalty aggregation.
# 32 (default) means each /32 (individual IPv4) is tracked separately.
# Setting to 24 groups a /24 together -- useful against distributed attacks
# from the same netblock, at the cost of potential false positives.
PerSourceNetBlockSize 32 128  # IPv4 /32, IPv6 /128

The crash penalty is the most important tuning knob. A pre-auth crash is not normal behavior -- it indicates either a bug being triggered or an active exploit attempt. A 60-second penalty on crash is conservative; some environments that are active targets set this to 300 or higher. The relationship between PerSourcePenalties and fail2ban is complementary rather than redundant: PerSourcePenalties operates inside sshd with sub-second response time and no log parsing; fail2ban operates at the firewall level and can produce permanent blocks. Running both provides defense in depth at different layers.

StrictModes: Enforcing Key File Permissions

StrictModes yes instructs sshd to refuse authentication if the user's home directory, .ssh/ directory, or authorized_keys file have permissions that are too permissive -- specifically, if they are group- or world-writable. This is enabled by default but worth making explicit in a hardened config. The failure mode when it's absent or disabled is silent: a user's key file works fine until the permissions get corrected, at which point sshd starts refusing auth for no apparent reason. Explicit documentation in config prevents that confusion and makes it visible to auditors.

Compression: No Pre-Auth Compression

The Compression directive has three settings: yes, delayed, and no. Setting it to yes allows compression during the pre-authentication phase, which creates an oracle condition analogous to the CRIME and BREACH attacks against TLS: an attacker who can influence session content and observe ciphertext length can infer information about the session. delayed -- historically the default -- defers compression until after authentication, which eliminates the pre-auth oracle. no disables compression entirely and is the hardened choice for servers where bandwidth is not a constraint. DISA STIG for RHEL 9 explicitly prohibits pre-auth compression. If your workloads genuinely benefit from compression (bulk file transfers over slow links), delayed is acceptable; yes is not.

T1572  Protocol Tunneling

The AllowTcpForwarding no, PermitTunnel no, and GatewayPorts no directives above are not cosmetic hardening. Leaving TCP forwarding enabled on servers that don't require it means any authenticated user can route arbitrary network traffic through the encrypted SSH channel -- creating covert command-and-control paths that bypass firewall egress controls entirely and are invisible to most packet inspection tools. This is T1572 in practice: the attacker uses a legitimate, trusted protocol as an outer envelope to move traffic that would otherwise be blocked or detected. Disabling these features removes the capability for all authenticated sessions; if specific users genuinely need port forwarding, scope it with a Match block rather than enabling it globally.

SFTP in a Chroot: Locking Down File Transfer Access

The sshd_config block above uses Subsystem sftp internal-sftp, which is a prerequisite for chroot-confined SFTP sessions. But the subsystem line alone does nothing to restrict what users can access. The actual confinement requires a Match block that targets the users or groups who should be limited to SFTP, sets their chroot directory, and disables shell access.

The chroot directory must be owned by root and not writable by any other user -- sshd enforces this and will refuse to chroot if it isn't. The user's actual writable directory sits inside the chroot. A common pattern is /srv/sftp/username as the chroot root, with /srv/sftp/username/uploads owned by the user.

/etc/ssh/sshd_config -- SFTP chroot Match block
# Place this at the END of sshd_config -- Match blocks must come last
Match Group sftponly
    ChrootDirectory        /srv/sftp/%u
    ForceCommand           internal-sftp
    AllowTcpForwarding     no
    AllowAgentForwarding   no
    X11Forwarding          no
    PermitTTY              no
terminal -- provision a chroot SFTP user
# Create the group and user
$ groupadd sftponly
$ useradd -m -s /usr/sbin/nologin -G sftponly transferuser

# Create chroot directory -- must be root-owned, not world-writable
$ mkdir -p /srv/sftp/transferuser/uploads
$ chown root:root /srv/sftp/transferuser
$ chmod 755 /srv/sftp/transferuser

# Give the user write access to their uploads directory only
$ chown transferuser:sftponly /srv/sftp/transferuser/uploads
$ chmod 750 /srv/sftp/transferuser/uploads

The %u token in ChrootDirectory expands to the authenticating username, which allows a single Match block to serve multiple SFTP users with isolated directory trees. If SFTP is not required at all on a given server, remove the Subsystem sftp line entirely rather than leaving it present and unconfined.

Key Exchange and Cipher Selection in Depth

The cipher and KEX list in sshd_config is where many hardening guides either get overly conservative (disabling algorithms that are fine) or not conservative enough (leaving legacy negotiation open for "compatibility"). Here's the actual current state as of early 2026.

mlkem768x25519-sha256 is the current default post-quantum hybrid key exchange algorithm as of OpenSSH 10.0 (released April 2025). It combines the classical Curve25519 ECDH with ML-KEM-768, the NIST-standardized lattice-based algorithm (FIPS 203). The hybrid approach means that even if the lattice component is broken, the session retains Curve25519 security -- and if classical ECDH is broken by a future quantum computer, the lattice component provides post-quantum protection. If you are running OpenSSH 9.x, the default was sntrup761x25519-sha512 (NTRU Prime, in use since 8.5); that algorithm remains cryptographically sound but is no longer the project's preferred default. Both should appear in your KexAlgorithms list to cover mixed-version fleets. CISA's 2025 post-quantum guidance recommends enabling hybrid KEX for any system that may store traffic for later decryption.

diffie-hellman-group14-sha256 is currently in a yellow/warn state in ssh-audit. Group 14 is a 2048-bit MODP group providing approximately 112 bits of classical security. NIST SP 800-131A Rev 2 (2019) classifies 2048-bit key agreement as acceptable; the Initial Public Draft of Rev 3 (October 2024) places 112-bit security strength algorithms on a deprecation schedule through December 31, 2030, after which they will be restricted to legacy-use processing only. If your server still needs group14 for legacy client compatibility, restrict it to sha256 rather than sha1, and plan to remove it before 2030. Groups 16 (4096-bit, ~140-bit security) and 18 (8192-bit) have no near-term deprecation concern under either Rev 2 or the Rev 3 draft.

CBC ciphers in SSH are vulnerable to a class of attacks documented by Albrecht, Paterson, and Watson (IEEE S&P 2009) that exploit the way CBC mode interacts with SSH's binary packet protocol. The attack is not trivially exploitable in modern implementations due to timing mitigations, but the correct response is removal, not mitigation. AES-CTR with ETM MACs and AES-GCM are both safe and widely supported.

Sources

The IETF's RFC 4253 (SSH Transport Layer Protocol, 2006) specifies the baseline cipher suite. RFC 8308 (Extension Negotiation in SSH) and the OpenSSH project's own 10.0 release notes are the authoritative references for current algorithm status. NIST SP 800-131A Rev 2 governs current algorithm transitions; the Rev 3 Initial Public Draft (October 2024) outlines the transition to 128-bit minimum security strength by end of 2030. CVE details for regreSSHion (CVE-2024-6387) and the pre-auth DoS (CVE-2025-26466) are from the OpenSSH security page and the respective Qualys TRU advisories.

Authentication Hardening

Disabling password authentication is the single highest-impact SSH hardening step. Password authentication exposes the server to brute-force attacks T1110.001, credential-stuffing attacks from previously breached password lists T1110.004, and social engineering scenarios where a user's password is known. None of these vectors apply when key-based authentication is the only option.

But key management introduces its own problems. Unmanaged authorized_keys files accumulate over time -- former employees, decommissioned automation accounts, test keys that were never removed. A 2023 SSH key audit by a mid-sized financial services firm (described in a HashiCorp Vault deployment case study) found that over 40% of their authorized SSH keys belonged to users who no longer had active accounts. Those keys still worked.

OpenSSH 10.0 and the sshd-auth Binary Split

OpenSSH 10.0 introduced a significant architectural change relevant to your hardening posture: the authentication code was extracted into a separate binary called sshd-auth. Prior to this change, the same sshd process handled both pre-authentication negotiation and post-authentication session management, meaning the full weight of the authentication codebase remained loaded in memory throughout the session -- including code paths that were no longer reachable. The new architecture unloads sshd-auth from memory after authentication completes, reducing the runtime attack surface for privilege escalation attempts in authenticated sessions.

The operational implication: distributions that package OpenSSH 10.x now need to ship sshd-auth as a separate binary, and it must be present and executable on the system for sshd to function. If you're building OpenSSH from source, managing chroot environments, or using custom packaging pipelines, verify that sshd-auth is included. On systemd-based systems you can confirm both binaries are in place with which sshd-auth before upgrading production hosts.

Authorized Keys File Placement and Permissions

The AuthorizedKeysFile directive controls where sshd looks for authorized keys. The default .ssh/authorized_keys is fine for standard deployments. For systems where centralized key management is required, OpenSSH supports AuthorizedKeysCommand -- a mechanism to call an external script or binary that returns authorized keys dynamically. This is the pattern used by tools like AWS EC2 Instance Connect and HashiCorp Vault SSH secrets engine.

The operational advantage of AuthorizedKeysCommand over static files is that key revocation is immediate and centralized. With static authorized_keys files distributed across a fleet, removing a compromised key means touching every server. With a command-based lookup, you remove the key from the backing store once and every server stops accepting it on the next connection attempt -- no file edits, no Ansible playbook run required.

T1098.004  Account Manipulation: SSH Authorized Keys    T1552.004  Unsecured Credentials: Private Keys

Adversaries who gain write access to a user's home directory can insert their own public key into authorized_keys and establish persistence that survives password resets and account lockouts (T1098.004). This is a preferred post-exploitation persistence technique because the entry is silent, requires no running process, and remains valid indefinitely unless the file is audited. Separately, unprotected private key files discovered on a compromised workstation or backup volume (T1552.004) can be used directly for lateral movement -- no brute force, no log noise. Centralized key management via AuthorizedKeysCommand mitigates T1098.004 directly: a single revocation removes access fleet-wide on the next connection attempt.

A minimal but production-capable implementation queries an internal API or LDAP directory. The script must be owned by root, not world-writable, and must accept the authenticating username as its first argument. sshd passes the username automatically via the %u token.

/usr/local/bin/fetch-authorized-keys -- minimal implementation
#!/bin/bash
# Called by sshd as: fetch-authorized-keys <username>
# Returns authorized public keys for the given user, one per line.
# Exit 0 with no output = no keys found (auth will fail).
# Exit non-zero = sshd treats the command as failed (auth will fail).
USERNAME="$1"

# Validate input -- never pass unsanitized username to downstream calls
if [[ ! "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]]; then
    exit 1
fi

# Example: query an internal key store API
curl -sf --max-time 2 \
    -H "Authorization: Bearer ${KEY_STORE_TOKEN}" \
    "https://keys.internal.example.com/authorized-keys/${USERNAME}"
/etc/ssh/sshd_config -- wire up the command
# Script must be owned root, not world-writable
AuthorizedKeysCommand      /usr/local/bin/fetch-authorized-keys %u
AuthorizedKeysCommandUser  nobody
# Keep the static file as a fallback only if needed during transition
# AuthorizedKeysFile .ssh/authorized_keys

For HashiCorp Vault users, the Vault SSH secrets engine can serve as the backing store directly. Vault issues short-lived signed certificates or OTP-based credentials rather than storing long-lived public keys, which eliminates the revocation problem entirely -- there is nothing to revoke because each credential expires on its own. The AuthorizedKeysCommand pattern and the certificate-based auth model described later in this guide are complementary: use AuthorizedKeysCommand for teams that need centralized key management without the operational overhead of a full SSH CA; use certificate-based auth for fleets where scalability and time-limited access matter most.

terminal -- correct authorized_keys permissions
# sshd will refuse to use authorized_keys if permissions are too open
$ chmod 700 ~/.ssh
$ chmod 600 ~/.ssh/authorized_keys
$ chown -R $USER:$USER ~/.ssh

# Audit all authorized_keys files on the system
$ find /home /root -name authorized_keys -exec ls -la {} \; 2>/dev/null

# List key fingerprints from a specific authorized_keys file
$ ssh-keygen -l -f /home/deploy/.ssh/authorized_keys

Restricting Keys with Options

Each entry in an authorized_keys file can carry options that restrict what that key is permitted to do. This is frequently overlooked. A deployment key that only needs to run a single script should not have an unrestricted shell session. The options command, from, and no-port-forwarding are the most useful for locking down automation keys.

Pay particular attention to no-agent-forwarding. Unrestricted agent forwarding allows an attacker with root access on any intermediate host to attach to the forwarded agent socket and authenticate onward to other servers without ever touching the private key file -- a lateral movement technique documented as T1563.001 Remote Service Session Hijacking: SSH Hijacking. Disabling agent forwarding in sshd_config and enforcing no-agent-forwarding in authorized_keys for automation keys eliminates this path entirely.

~/.ssh/authorized_keys -- restricted key examples
# Restrict a key to a single command, from a specific source IP
from="10.0.1.50",command="/usr/local/bin/deploy.sh",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA...

# Restrict a backup key to rsync-only, no interactive shell
command="rsync --server --daemon .",no-pty,no-port-forwarding ssh-ed25519 AAAA...

Adding Multi-Factor Authentication

For systems where a compromised private key is a realistic threat model -- internet-facing jump hosts, production database servers, anything in scope for SOC 2 or PCI DSS -- layering TOTP-based MFA over key authentication significantly raises the bar. The libpam-google-authenticator module integrates with OpenSSH's PAM stack to require a TOTP code after successful key authentication.

terminal -- install and configure TOTP MFA
$ apt install libpam-google-authenticator
$ google-authenticator  # run as the user who will authenticate
/etc/pam.d/sshd -- add TOTP requirement
# Add this line near the top, BEFORE @include common-auth
auth required pam_google_authenticator.so nullok
/etc/ssh/sshd_config -- enable keyboard-interactive for MFA
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive

The AuthenticationMethods directive requires both factors to succeed. A valid key alone is not sufficient; the session will also prompt for the TOTP code. Note that the nullok option in the PAM configuration allows users who haven't yet enrolled in TOTP to continue authenticating -- this is useful during rollout but should be removed once all users are enrolled.

The rollout sequence matters. Enabling pam_google_authenticator.so without nullok before all users have enrolled will immediately lock out anyone who hasn't run google-authenticator. The safe path: deploy with nullok, verify enrollment completion via a script that checks for the ~/.google_authenticator file on every account in scope, then remove nullok in a second config push. Automate the verification step so you have evidence of 100% enrollment before the second push.

terminal -- verify TOTP enrollment before removing nullok
# Check which users in the sshusers group have NOT enrolled
$ for user in $(getent group sshusers | cut -d: -f4 | tr ',' ' '); do
    home=$(getent passwd "$user" | cut -d: -f6)
    [[ ! -f "$home/.google_authenticator" ]] && echo "NOT ENROLLED: $user"
done

# Once the above outputs nothing, remove nullok from /etc/pam.d/sshd
# auth required pam_google_authenticator.so   <-- no nullok
Tip

If you manage many servers, consider FIDO2/WebAuthn hardware keys rather than TOTP. OpenSSH 8.2+ natively supports [email protected] and [email protected] key types backed by hardware security keys (YubiKey, Solo, etc.). Hardware key authentication is phishing-resistant and doesn't require a shared TOTP secret on the server.

Network-Level and OS-Level Access Controls

SSH configuration alone is not enough. Defense in depth means SSH hardening sits inside a stack of other controls, not on top of nothing.

Firewall Rules

SSH should only be reachable from specific source addresses where operationally possible. For servers that are only administered from a corporate VPN or a bastion host, an iptables or nftables rule that restricts port 22 to a known management CIDR is one of the simplest and most effective controls available.

terminal -- restrict SSH with nftables
$ nft add rule inet filter input tcp dport 22 ip saddr 10.0.0.0/8 accept
$ nft add rule inet filter input tcp dport 22 drop

Fail2Ban and Rate Limiting

For servers where SSH must be accessible from the open internet, fail2ban or sshguard can automatically block IP addresses that generate authentication failures. Fail2ban reads the SSH auth log and creates temporary firewall blocks after a configurable number of failures within a time window.

/etc/fail2ban/jail.local
[sshd]
enabled  = true
port     = ssh
filter   = sshd
logpath  = /var/log/auth.log
maxretry = 3
findtime = 600
bantime  = 3600

Login Banners

A pre-authentication login banner serves two purposes: legal (establishing that unauthorized access is prohibited, which matters for prosecution) and operational (identifying the system to admins who manage many servers). The banner is displayed before authentication completes, so it applies to all connection attempts. The Banner directive in sshd_config points to the banner file.

/etc/ssh/banner
*******************************************************************************
  AUTHORIZED ACCESS ONLY

  This system is the property of [Organization Name].
  Unauthorized access or use is strictly prohibited and may be subject
  to civil and criminal penalties.

  All activity on this system is monitored and recorded.
  By continuing, you consent to this monitoring.
*******************************************************************************
Legal Note

Do not include information in your banner that could help an attacker -- system version strings, hostname patterns, or internal IP ranges. Banners serve a legal and deterrent function, not an informational one. Consult your legal team about jurisdiction-specific language, particularly for systems in EU or regulated-industry environments.

Running a Full Audit

With your hardened configuration in place, it's time to verify it objectively. The complete audit workflow uses three tools: ssh-audit for cryptographic policy verification, sshd -T for configuration parsing, and lynis or a CIS benchmark scanner for broader posture assessment.

terminal -- complete audit workflow
# 1. Validate sshd_config syntax before reloading
$ sshd -t

# 2. Dump the effective running configuration (all directives, resolved)
$ sshd -T

# 3. Run ssh-audit against localhost (from a separate terminal session)
$ ssh-audit localhost

# 4. Run ssh-audit with a specific policy file for CI/CD integration
$ ssh-audit --policy=/etc/ssh-audit/policy.txt localhost

# 5. Run Lynis for a broader system security audit
$ lynis audit system

# 6. Check for active SSH sessions and connected clients
$ ss -tnp | grep :22
$ who

The sshd -T command is particularly useful during audits because it outputs the complete effective configuration -- including defaults for directives you haven't explicitly set. This is what actually governs behavior, not just what's in the config file. Pipe it through grep to check specific directives quickly.

terminal -- check specific directives via sshd -T
$ sshd -T | grep -E "^(permitrootlogin|passwordauth|pubkeyauth|x11forwarding|allowtcpforwarding|kexalgorithms|ciphers|macs)"

Certificate-Based Authentication at Scale

For fleets larger than a handful of servers, managing individual authorized_keys files becomes operationally expensive and prone to drift. SSH certificates -- not to be confused with TLS/X.509 certificates -- solve this by introducing a trusted CA that signs user and host keys. Clients trust the CA, not individual keys.

This is the model used by Google's BeyondCorp infrastructure and described in their 2017 USENIX ;login: paper on SSH certificate deployment at scale. It eliminates the need to distribute individual public keys across every server and enables time-limited certificates, principal restrictions, and audit trails at the CA level.

terminal -- basic SSH CA setup
# Generate the CA key (keep this offline or in an HSM)
$ ssh-keygen -t ed25519 -f /etc/ssh/ca/user_ca -C "User CA"

# Sign a user's public key, valid for 8 hours, for principals alice and bob
$ ssh-keygen -s /etc/ssh/ca/user_ca \
    -I "alice-$(date +%Y%m%d)" \
    -n alice,bob \
    -V +8h \
    ~/.ssh/id_ed25519.pub

# On the server: trust the CA (add to sshd_config)
TrustedUserCAKeys /etc/ssh/user_ca.pub

Short-lived certificates (hours, not days) are the key operational advantage here. If a certificate is stolen or a user is terminated, it expires on its own schedule. Combined with an automated issuance workflow -- a bastion host that issues certificates after authenticating against your identity provider -- this becomes a robust zero-touch key management system.

Protecting the CA Key

The CA private key is the highest-value secret in this architecture. If it is compromised, an attacker can sign certificates for any principal on any server that trusts the CA. The CA key must never sit on an internet-reachable host. The two production-grade options are an offline CA (an air-gapped machine or USB key that is only connected when signing certificates) or a hardware security module (HSM) such as a YubiKey HSM or AWS CloudHSM, where the private key is generated inside the hardware and cannot be exported.

For teams using HashiCorp Vault, the Vault SSH secrets engine wraps the CA key inside Vault's seal mechanism and handles signing via the API. Operators never touch the private key directly, and every signing operation is logged with the requesting identity, the principal list, and the validity window. This provides the audit trail that compliance frameworks require while keeping the key material inaccessible to any individual operator.

terminal -- host certificate setup (often skipped, shouldn't be)
# Most SSH CA deployments sign user keys but skip host certificates.
# Host certificates let clients verify the server's identity via the CA
# instead of relying on known_hosts entries -- eliminating TOFU entirely.

# Generate a separate host CA (keep user CA and host CA distinct)
$ ssh-keygen -t ed25519 -f /etc/ssh/ca/host_ca -C "Host CA"

# Sign the server's host key
$ ssh-keygen -s /etc/ssh/ca/host_ca \
    -I "prod-db01-$(date +%Y%m%d)" \
    -h \
    -n prod-db01.internal.example.com \
    -V +52w \
    /etc/ssh/ssh_host_ed25519_key.pub

# Tell sshd to present the signed certificate
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub

# On clients: trust the host CA instead of individual host keys
@cert-authority *.internal.example.com ssh-ed25519 AAAA... Host CA
# Add this line to ~/.ssh/known_hosts or /etc/ssh/ssh_known_hosts

Host certificates eliminate the trust-on-first-use (TOFU) problem that plagues standard SSH deployments. Without them, clients accept the server's host key the first time and hope it doesn't change maliciously. With host certificates, the client validates the server's identity against the CA on every connection -- making adversary-in-the-middle attacks T1557 visible as certificate validation failures rather than silent accepted connections. DNS hijacking or ARP spoofing that redirects a first-time connection to an attacker-controlled host will present a certificate that doesn't validate against your CA -- and the client rejects it instead of silently caching the fraudulent key.

The Client Side: Where Server Hardening Gets Bypassed

Every hardening decision made on the server can be quietly undermined by a client that negotiates weaker algorithms because its own ~/.ssh/config or system-level /etc/ssh/ssh_config specifies them explicitly or overrides the server's preferred order. This is not theoretical -- legacy ssh_config files with Ciphers or KexAlgorithms lines copied from a 2015 guide are common in environments that have accumulated configuration debt. If a client advertises a weak cipher and the server still accepts it, negotiation will land there.

The client configuration worth enforcing follows the same principles as the server: prefer Ed25519 keys, prefer post-quantum hybrid KEX, disable weak algorithms explicitly, and use HashKnownHosts yes to prevent known_hosts files from leaking hostnames to anyone who gets read access to the file.

~/.ssh/config -- hardened client defaults
# Apply these defaults to all hosts unless overridden by a specific Host block
Host *
    IdentityFile              ~/.ssh/id_ed25519
    KexAlgorithms             mlkem768x25519-sha256,curve25519-sha256,diffie-hellman-group16-sha512
    HostKeyAlgorithms         ssh-ed25519,rsa-sha2-512,rsa-sha2-256
    Ciphers                   [email protected],[email protected],[email protected]
    MACs                      [email protected],[email protected]
    HashKnownHosts            yes
    ServerAliveInterval       60
    ServerAliveCountMax       3
    AddKeysToAgent            yes
    IdentitiesOnly            yes

IdentitiesOnly yes prevents the SSH agent from offering keys not explicitly listed in the config. Without it, a compromised or malicious server can observe every key your agent attempts during authentication -- including keys for other hosts -- effectively enumerating your private key inventory via the authentication handshake T1552.004. AddKeysToAgent yes keeps the private key in the agent after first use, so you enter the passphrase once per session rather than once per connection.

ProxyJump and Bastion Hosts

A bastion (jump) host is a hardened gateway server that sits at the network perimeter. Internal servers accept SSH only from the bastion's IP; direct access from the internet is blocked at the firewall level. This architecture means a compromised workstation credential alone is not sufficient to reach a production server -- the attacker also needs access to the bastion.

OpenSSH's ProxyJump directive (introduced in OpenSSH 7.3) handles the tunneling transparently. The client connects to the bastion, which forwards the connection onward to the destination -- but the end-to-end encryption is between the client and the destination server, not terminated at the bastion. The bastion sees a TCP stream, not decrypted SSH traffic.

~/.ssh/config -- ProxyJump bastion configuration
# Define the bastion host
Host bastion
    HostName      bastion.example.com
    User          admin
    IdentityFile  ~/.ssh/id_ed25519
    Port          22

# All production hosts route through the bastion automatically
Host prod-*
    ProxyJump     bastion
    User          deploy
    IdentityFile  ~/.ssh/id_ed25519

# Now: ssh prod-db01 connects via bastion transparently

For the bastion itself, apply the strictest version of the sshd_config hardening in this guide -- it is the highest-value target in the architecture. Disable all forwarding directives on the bastion except what ProxyJump requires (AllowTcpForwarding must be yes on the bastion for ProxyJump to function). On all internal servers, restrict SSH access at the firewall to the bastion's source IP only. This architecture directly constrains T1021.004 Remote Services: SSH as a lateral movement technique: even with valid credentials, firewall rules that permit SSH only from the bastion's IP mean those credentials can't reach production servers directly. The attacker must first compromise the bastion -- an additional, hardened, monitored chokepoint. The combination of network-level restriction and certificate-based authentication with short-lived certificates makes lateral movement from a compromised internal host significantly harder.

Ongoing Operations: Keeping the Posture

A hardened configuration that drifts back toward defaults within three months isn't hardening -- it's a snapshot. Sustained SSH security posture requires a few operational practices.

First, pin your hardening configuration as code. Whether that's an Ansible role, a Chef cookbook, or a Terraform remote-exec provisioner, the sshd_config state should be defined in version control and enforced on a schedule. Any manual change to sshd_config should be caught by your configuration management system on the next convergence run.

Second, integrate ssh-audit into your CI/CD pipeline or vulnerability scanning workflow. The tool supports JSON output (ssh-audit --json) and policy files, making it straightforward to fail a pipeline when new servers don't meet your defined policy. Several popular vulnerability scanners including Tenable.io and Qualys include SSH cipher checks in their plugin libraries, and both align their severity ratings with NIST guidance.

The policy file is what most teams skip. Without it, ssh-audit grades against a generic baseline. With a policy file pinned to your specific approved algorithm list, any deviation -- a new server provisioned with a missing drop-in config, a package upgrade that changed the cipher order -- becomes a pipeline failure rather than a silent drift. The policy file format is straightforward to generate from a known-good server.

terminal -- generate and use an ssh-audit policy file
# Generate a policy from a known-good server (do this once, commit the output)
$ ssh-audit --make-policy=sudowheel-ssh-policy.txt your.hardened-reference.server

# Audit a new server against the policy -- exits non-zero on any deviation
$ ssh-audit --policy=sudowheel-ssh-policy.txt new.server.com

# JSON output for pipeline integration (parse with jq)
$ ssh-audit --json new.server.com | jq '.recommendations[] | select(.level == "fail")'

# Scan an entire fleet (parallel, 20 at a time)
$ cat /etc/inventory/ssh-hosts.txt | \
    xargs -P20 -I{} ssh-audit --policy=sudowheel-ssh-policy.txt {} 2>&1 | \
    grep -E "FAIL|WARN"

Third, audit authorized_keys files regularly. A simple cron job that runs find /home /root -name authorized_keys and emails the output to a security address once a week will surface new keys that weren't provisioned through your standard workflow. Attacker-inserted keys added for persistence T1098.004 appear as entries with no traceable provisioning event -- without a periodic review, they persist indefinitely. For SOC 2 Type II compliance, evidence of this review may be required.

The CIS Controls Implementation Guide v8 (Section 4.2) notes that SSH key sprawl is among the most consistent findings in cloud security assessments -- organizations regularly surface hundreds of authorized keys with no traceable owner. The guidance is direct: the fix is operational, not technical. A key lifecycle process needs to exist before the accumulation starts, not after it's already a problem.

Compliance Mapping

If you're hardening SSH to meet a specific compliance framework, the following mapping covers the major ones as of early 2026.

CIS Benchmark for Ubuntu Linux 24.04 LTS (Level 1 and 2) covers SSH configuration in section 5.2. Level 1 requires disabling root login, password authentication, and unused forwarding features. Level 2 adds cipher and MAC restrictions consistent with what's described above. The CIS benchmark is the most operationally practical compliance reference for SSH hardening.

NIST SP 800-53 Rev 5 addresses SSH under controls AC-17 (Remote Access), IA-3 (Device Identification and Authentication), and SC-8 (Transmission Confidentiality and Integrity). The cryptographic requirements in SC-13 require FIPS 140-3 validated modules for systems in federal environments, which means using a FIPS-compliant OpenSSH build. NIST SP 800-131A Rev 2 (2019) remains the current governing document for algorithm transitions; the Rev 3 Initial Public Draft (October 2024) adds a deprecation schedule for 112-bit security strength algorithms -- including 2048-bit RSA and DH group14 -- through December 31, 2030, after which they transition to legacy-use only. Organizations planning multi-year SSH infrastructure should treat the Rev 3 IPD timeline as their planning horizon even before the final publication.

PCI DSS v4.0 (effective March 2025 for new requirements) requires multi-factor authentication for all non-console administrative access in requirement 8.4.2. Password authentication alone is no longer sufficient for systems that store, process, or transmit cardholder data. This requirement is a direct control against T1078 Valid Accounts: stolen SSH credentials are the most common SSH compromise vector, and MFA ensures those credentials are insufficient on their own. Key-only authentication is technically compliant if the key is protected by a passphrase and the passphrase is something the user knows, but in practice most QSAs will expect an explicit second factor.

DISA STIG for Red Hat Enterprise Linux 9 (STIG ID RHEL-09-255xxx series) is the most prescriptive of the common benchmarks. It prohibits specific algorithms by name, requires LogLevel VERBOSE, mandates the AllowUsers or AllowGroups directive, and requires specific file permissions on the sshd_config file itself.

Sources

CIS Benchmarks are available at cisecurity.org. DISA STIGs are published at public.cyber.mil/stigs. NIST SP 800-131A Rev 2 is at nvlpubs.nist.gov; the Rev 3 Initial Public Draft (October 2024) is at the same domain. PCI DSS v4.0 is available to registered users of the PCI Security Standards Council portal at pcisecuritystandards.org. The OpenSSH security advisory page listing all published CVEs is at openssh.com/security.html.

Where to Go From Here

A properly hardened SSH configuration is not a weekend project that gets checked off a list. It's a baseline that needs version control, automated verification, and periodic review as OpenSSH releases new versions and the cryptographic landscape shifts. OpenSSH 10.0 (released April 2025) removed DSA entirely and changed the default KEX to mlkem768x25519-sha256. OpenSSH 10.1 introduced a warning when a non-post-quantum key exchange is negotiated, controllable via the new WarnWeakCrypto option in ssh_config. OpenSSH 10.2 began the process of deprecating SHA1 SSHFP DNS records; a subsequent release will ignore them entirely and limit ssh-keygen -r output to SHA256 records only. Each of these changes has operational implications for how your clients and automation are configured. Stay subscribed to the OpenSSH announce mailing list -- the release notes are detailed and give you lead time before deprecated algorithms actually break.

The tools and configuration in this guide represent current best practice as of early 2026, targeting OpenSSH 10.x. If your fleet includes systems still running OpenSSH 9.x, the functional configuration is largely the same, but the default KEX differs -- verify that both mlkem768x25519-sha256 and sntrup761x25519-sha512 appear in your KexAlgorithms list to stay compatible across versions without falling back to classical DH. Run ssh-audit against your fleet. Commit your sshd_config to version control. Audit your authorized_keys files. Those three habits, practiced consistently, will keep your SSH posture sound through multiple OpenSSH generations and compliance framework revisions.