guest@6admin.io:~$

SSH Security Hardening: Access Control and Monitoring

SSH is the front door of almost every fleet, which is exactly why it is the first thing worth getting right. A loose configuration does not just expose one host, it hands an attacker the three things they want most: a way in, a way across and a way to stay. The point of this note is not to recite a compliance checklist, it is to describe the configuration I actually deploy, the reasons behind each choice and the monitoring that turns “someone logged in” into “this person, from there, at that time”.

Three principles carry the whole thing. No account holds more privilege than its job requires, root first among them. Every human action traces back to a named individual, which rules out shared keys. And service accounts, which are the ones nobody watches, are the most tightly boxed of all.

What a loose configuration actually costs

Root over SSH is the obvious one and it has to go entirely, because every softer variant still leaves a door ajar:

# none of these belong on a hardened host
PermitRootLogin yes
PermitRootLogin without-password   # still dangerous
PermitRootLogin prohibit-password  # still allows key-based root

A direct root compromise bypasses every control below it, moves laterally without friction, persists through a planted key and can rewrite the very logs that would have caught it. There is no version of “root over SSH” that survives contact with a real threat model.

Shared keys are the subtler failure. The day two people authenticate with the same private key, accountability is gone: the logs show a login, not who performed it and one careless laptop compromises the whole team at once. A key belongs to exactly one person.

Service accounts authenticating by password are the third classic. A password lives in a script or a config file, gets reused across machines and answers to brute force. Service accounts authenticate by key, with a command restriction, or they do not authenticate at all.

The server configuration

This is the sshd_config I start from, trimmed of everything that is not a deliberate choice:

# /etc/ssh/sshd_config

# root is never reachable over SSH
PermitRootLogin no

# keys only, enforced
PasswordAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
PermitEmptyPasswords no
KbdInteractiveAuthentication no

# host keys: modern first
HostKey /etc/ssh/ssh_host_ed25519_key
HostKey /etc/ssh/ssh_host_ecdsa_key

# connection surface
Port 2222
MaxAuthTries 3
LoginGraceTime 30
MaxStartups 10:30:60

# cryptography: AEAD and ETM only, curve25519 key exchange
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512

# features off until something needs them
AllowTcpForwarding no
AllowStreamLocalForwarding no
PermitTunnel no
X11Forwarding no
AllowAgentForwarding no

# sessions
ClientAliveInterval 300
ClientAliveCountMax 2
TCPKeepAlive no

# logging
LogLevel VERBOSE
SyslogFacility AUTHPRIV
PrintLastLog yes

Two notes on what is deliberately absent. Protocol 2 is gone: SSHv1 was removed from OpenSSH years ago, so the directive is dead weight that recent daemons reject. And ChallengeResponseAuthentication has been renamed KbdInteractiveAuthentication since OpenSSH 8.7; the old name still works as an alias, but there is no reason to keep writing a deprecated spelling into a file meant to last.

The feature restrictions default to off on purpose. Forwarding, tunneling and agent forwarding are powerful, occasionally necessary and almost never needed by everyone, so they are granted per group rather than globally.

Per-group and per-source controls

A flat policy is the wrong shape, because an administrator, a developer and a backup account do not need the same thing. Match blocks let the policy follow the role:

Match Group users
    AllowTcpForwarding no
    AllowStreamLocalForwarding no
    PermitTunnel no

Match Group sysadmins
    AllowTcpForwarding local
    AllowStreamLocalForwarding yes

Match Group service_accounts
    AllowTcpForwarding no
    PermitTTY no
    ForceCommand /opt/scripts/service_wrapper.sh

Match Address 192.168.100.0/24
    AllowGroups dev_team

Match Address 10.0.0.0/8
    AllowGroups internal_users

The source-based blocks are not a security boundary on their own, since an address can be spoofed on a flat network, but layered on top of key authentication they cut the exposed surface to the networks that have any business reaching a given account.

Service accounts, boxed in

A service account gets a restricted shell, a key per human who stands behind it and a wrapper that refuses anything outside its mandate. The key comment carries the attribution, so the logs name a person even when the account is shared:

useradd -r -s /usr/sbin/nologin -d /opt/backup backup_svc
install -d -m 700 -o backup_svc -g backup_svc /opt/backup/.ssh
# authorized_keys: one line per human, each boxed to the wrapper
command="/opt/backup/wrapper.sh",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,restrict ssh-ed25519 AAAA...alice@company-backup
command="/opt/backup/wrapper.sh",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,restrict ssh-ed25519 AAAA...bob@company-backup

The restrict option is the modern shorthand that disables every forwarding and tty feature at once, so a future OpenSSH feature is denied by default rather than silently allowed. The wrapper then whitelists the exact commands the account may run and logs the rest as an incident:

#!/bin/bash
# /opt/backup/wrapper.sh
logger -t backup_service "cmd='$SSH_ORIGINAL_COMMAND' from $(echo "$SSH_CLIENT" | cut -d' ' -f1)"
case "$SSH_ORIGINAL_COMMAND" in
    "rsync --server"*) exec $SSH_ORIGINAL_COMMAND ;;
    "backup_database")  exec /opt/backup/db_backup.sh ;;
    *)
        logger -t backup_service "DENY: $SSH_ORIGINAL_COMMAND"
        echo "unauthorized command" >&2
        exit 1 ;;
esac

Certificates, when the fleet outgrows authorized_keys

Past a handful of hosts, copying public keys around stops scaling and nobody ever cleans up the stragglers. An SSH certificate authority replaces that sprawl with a signature that carries its own expiry, so access lapses on its own instead of lingering in a file for years:

# the CA, kept offline
ssh-keygen -t ed25519 -f ssh_ca -C "SSH CA"

# a short-lived, constrained user certificate
ssh-keygen -s ssh_ca -I alice.johnson -n alice.johnson \
    -O source-address=192.168.1.0/24 \
    -O permit-pty \
    -V +1w \
    alice.johnson.pub

# the servers simply trust the CA
echo "TrustedUserCAKeys /etc/ssh/ssh_ca.pub" >> /etc/ssh/sshd_config

The win is revocation by expiry rather than by cleanup: a certificate valid for a week is a problem that solves itself, where an orphaned authorized_keys line is a backdoor that waits.

Second factor, done with the right directive

A hardware-backed key (a FIDO2 ed25519-sk) is the cleanest second factor, because the private half never leaves the token. Where a TOTP second factor is required on top of the key, it rides on keyboard-interactive and the directive to enable is the current one, not the deprecated alias:

apt-get install libpam-google-authenticator
echo "auth required pam_google_authenticator.so" >> /etc/pam.d/sshd

# in sshd_config
KbdInteractiveAuthentication yes
AuthenticationMethods publickey,keyboard-interactive

AuthenticationMethods publickey,keyboard-interactive is what makes it a real second factor: the key is demanded first and the code only after, so one without the other gets nowhere.

The bastion

SSH exposed straight to the internet is a service every scanner on earth can see, brute-force without limit and fingerprint for a version to exploit. A bastion collapses that surface to a single hardened entry point, stricter than the hosts behind it:

# bastion sshd_config, tightened for exposure
PermitRootLogin no
AllowGroups bastion_users
ForceCommand /usr/local/bin/bastion_menu.sh
MaxAuthTries 2
LoginGraceTime 15
MaxStartups 3:50:10
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
LogLevel VERBOSE

Behind it, the internal hosts accept connections only from the bastion’s address, so the only way in is through the one box that is watched, recorded and patched first.

Fail2ban, for the noise

Fail2ban does not stop a determined attacker with a botnet and it is not meant to. What it does is drain the flood of automated brute force so the logs stay readable and the obvious offenders get dropped at the firewall:

# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 3600
findtime = 600
maxretry = 3
backend  = systemd

[sshd]
enabled  = true
port     = 2222
maxretry = 3
bantime  = 86400

On a key-only server there is no password to brute-force in the first place, so fail2ban here is about log hygiene and firewall pressure, not about being the thing that keeps attackers out. That job belongs to the key.

Monitoring that names names

Verbose logging is worth nothing if nobody reads it, so the accepted logins get parsed into something that can raise an alert and feed a SIEM:

#!/bin/bash
# ssh_monitor.sh
tail -F /var/log/auth.log | while read -r line; do
    case "$line" in
        *"sshd"*"Accepted"*)
            user=$(sed -n 's/.*Accepted [a-z]* for \([A-Za-z0-9._-]*\).*/\1/p' <<<"$line")
            ip=$(sed -n 's/.*from \([0-9.]*\).*/\1/p' <<<"$line")
            logger -t ssh_monitor -p auth.notice "login user=$user src=$ip"
            [ "$user" = root ] && echo "root login from $ip" | \
                mail -s "SSH ALERT" security@company.example
            ;;
    esac
done

The key-usage audit is the other half, because authorized_keys files accumulate and nobody prunes them. A periodic pass that counts keys per account, flags the ones without a command restriction and surfaces private keys sitting in home directories, turns an invisible sprawl into a reviewable report:

#!/bin/bash
# ssh_key_audit.sh
for home in /home/*; do
    ak="$home/.ssh/authorized_keys"
    [ -f "$ak" ] || continue
    printf '%s: %s keys' "$(basename "$home")" "$(wc -l < "$ak")"
    grep -q 'command=' "$ak" && printf ' (restricted)' || printf ' (UNRESTRICTED)'
    printf '\n'
done

Where to stop

The honest footnote is that OpenSSH itself has an exceptional record: critical protocol vulnerabilities are rare and the serious recent ones, regreSSHion (CVE-2024-6387) among them, hit the portable build rather than OpenBSD’s own. That record is a reason to keep the daemon current, not a reason to skip the hardening, because almost everything above defends against misconfiguration and credential abuse rather than against a flaw in SSH. The daemon has rarely been the weak link. The configuration around it usually is.

A last word on the clever tricks. Port knocking and single-packet authorization add a layer and they also add a way to lock yourself out when a sequence breaks; obscurity is a supplement to hardening, never a substitute. Multiplexing SSH and HTTPS on one port with sslh is operationally handy and strategically weak, for the reasons its own article lays out at length. None of it replaces the three things that actually carry the weight: no root, one key per person and logs that someone reads.

[edit: 2026] The Ansible implementation of all this: the ssh and ssh-users roles.