m4nifest0-tech

Search the site

Cybersecurity

SSH hardening on Debian: a practical checklist

ed25519 keys, drop-in configuration, root and password logins disabled, fail2ban and a testing method so you don't lock yourself out of a Debian server.
Published on Updated on 8 min readLevel: Basic

In most infrastructures SSH is the main administration channel: whoever reaches it with valid credentials gets control of the system. This guide describes a sober configuration for Debian 12 and 13: key-only authentication, root excluded, access limited to a group of administrators, limited attempts and automatic banning of insistent clients. Every step is verifiable and the order is designed so you don't lose access to the server.

Ground rule: don't lock yourself out

Every change to sshd can make the server unreachable. There is only one rule: until the new configuration has been tested from a second connection, the session that is already open must not be closed.

Warning

Keep at least one SSH session open for the whole duration of the changes and test each change from a second terminal. systemctl reload ssh does not interrupt existing sessions, so the open one stays available to undo a mistake. If the server is a VM or a container on Proxmox, the hypervisor console (for an LXC, pct enter from the node) is an emergency access independent of SSH.

ed25519 keys

Generating the key pair

On the client, generate an ed25519 pair. The passphrase protects the private key if the file is copied; the -a option increases the rounds of the key derivation function that protects it.

On the client
ssh-keygen -t ed25519 -a 100 -C "admin@laptop"

The private key (~/.ssh/id_ed25519) never leaves the client. The .pub file is the only one to distribute.

Installing the public key on the server

On the client
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]

The command adds the key to the user's ~/.ssh/authorized_keys file on the server and still requires password authentication, so it must be run before disabling it. Right afterwards, verify key login in a new session.

Configuration through a drop-in

Since Debian 12, the main file /etc/ssh/sshd_config includes the *.conf files in the /etc/ssh/sshd_config.d/ directory. It is best to leave the main file, which is managed by the package, untouched and write your changes in a dedicated file: updates then produce no conflicts.

One OpenSSH rule is decisive: for each keyword, the first value read wins. The Include sits at the top of the main file and the files in the directory are read in lexical order, so a low numeric prefix takes precedence over the others. Some preconfigured images add their own files, such as 50-cloud-init.conf, with different values: 10-hardening.conf comes before them and wins.

/etc/ssh/sshd_config.d/10-hardening.conf (on the server)
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
MaxAuthTries 3
AllowGroups ssh-users

What the directives mean:

  • PermitRootLogin no prevents direct login as root. Administration goes through a named user and sudo, which leaves a trace of who ran what. Debian's default value, prohibit-password, still allows root with a key.
  • PasswordAuthentication no and KbdInteractiveAuthentication no must both be set. With UsePAM yes, the default on Debian, the keyboard-interactive method can ask for the system password through PAM and bypass the first directive.
  • AuthenticationMethods publickey explicitly declares the only method allowed.
  • MaxAuthTries 3 reduces attempts per connection (the default is 6).
  • AllowGroups ssh-users limits access to the members of the group. AllowUsers does the same with usernames; using only one of the two directives keeps the rule readable.
On the server
sudo groupadd ssh-users
sudo usermod -aG ssh-users utente

Important

The group must be created and populated before reloading the service. With AllowGroups pointing to an empty or nonexistent group, nobody could log in anymore.

Note

Every key offered by the SSH agent counts as one attempt. With many keys loaded, MaxAuthTries 3 can produce the "Too many authentication failures" error before the right key is even tried. On the client, the fix is IdentitiesOnly yes and IdentityFile in ~/.ssh/config, so that only the intended key is offered.

Verifying and reloading

Check the syntax before reloading. The command prints nothing if the configuration is valid.

On the server
sudo sshd -t
sudo systemctl reload ssh

With the first session still open, from a second terminal try a normal key login (ssh [email protected]) and then verify that the password is really rejected:

On the client, in a second session
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password [email protected]
[email protected]: Permission denied (publickey).

The effective values, after all the files have been merged, can be read with sshd -T. It is the most reliable check: if a value does not match the one written in the drop-in, another file is overriding it.

On the server
sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|authenticationmethods|maxauthtries|allowgroups) '
permitrootlogin no
maxauthtries 3
passwordauthentication no
kbdinteractiveauthentication no
authenticationmethods publickey
allowgroups ssh-users

The order of the lines may vary between OpenSSH versions.

fail2ban

fail2ban reads the authentication logs and blocks, for a set time, the addresses that fail repeatedly. With key-only authentication it adds no cryptographic protection: it reduces noise in the logs and the load caused by pointless attempts. It remains useful as defense in depth.

On the server
sudo apt install fail2ban
/etc/fail2ban/jail.d/sshd.conf (on the server)
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
ignoreip = 127.0.0.1/8 ::1 203.0.113.10

backend = systemd makes it read messages from the journal, useful when /var/log/auth.log does not exist. In ignoreip it is best to put the address you administer from, so you don't ban yourself during tests.

On the server
sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 0
|  |- Total failed:     0
|  `- Journal matches:  _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
   |- Currently banned: 0
   |- Total banned:     0
   `- Banned IP list:

The exact shape of the output depends on the version. To test the jail, from a test host other than the one listed in ignoreip, attempt a few logins with a nonexistent username: every "Invalid user" line in the journal must increase the failure counter.

Tip

Recent OpenSSH versions separate the per-connection process (sshd-session) from the listener. If the counter stays at zero, the most likely cause is an outdated filter: check it with sudo fail2ban-regex systemd-journal /etc/fail2ban/filter.d/sshd.conf. To remove an unwanted ban: sudo fail2ban-client set sshd unbanip 203.0.113.50.

What you don't need

  • Changing the port. It is not a security measure: it reduces bot noise, not exposure, and it complicates firewall rules, tools and documentation. To really reduce exposure, reach SSH only from the management network, as described in network segmentation with VLANs, or through a WireGuard VPN.
  • Hand-tuning the cryptographic algorithms. OpenSSH's defaults on Debian are reasonable. Changing Ciphers, MACs or KexAlgorithms only makes sense for compliance requirements and risks incompatibility with clients.

Next steps: second factor and certificates

The next step is a second factor: a TOTP code requested through PAM and combined with the key via AuthenticationMethods publickey,keyboard-interactive. It requires re-enabling KbdInteractiveAuthentication and configuring PAM, so it should be tried in a lab, with the backup session open.

The alternative, suited to many servers, is SSH certificates: a certificate authority (CA) key signs user keys with a short expiry, and servers trust the CA through TrustedUserCAKeys, without distributing lists of public keys. The drop-in can be applied to multiple hosts with a playbook, as in the flow described in VM provisioning with cloud-init and Ansible.

Final checklist

  • ed25519 key generated with a passphrase, installed with ssh-copy-id and tested in a new session
  • 10-hardening.conf drop-in created, main file untouched
  • Root, password and keyboard-interactive disabled
  • ssh-users group created and populated, AllowGroups active
  • MaxAuthTries reduced and IdentitiesOnly yes on the client
  • sshd -t without errors, systemctl reload ssh run with a session still open
  • Effective values confirmed with sshd -T and password login rejected
  • fail2ban active with the sshd jail and counters verified
  • SSH reachable only from the management network or via VPN
  • Second factor or SSH certificates evaluated

Related articles