BLOG // [SECURITY] SSH Key Authentication and Server Hardening
SSH Key Authentication and Server Hardening

SSH Key Authentication and Server Hardening

A practical guide to SSH key authentication: choosing algorithms, generating keys, client configuration, sshd hardening, advanced authorized_keys, SSH certificates, brute-force protection and post-quantum readiness.

SECURITY

SSH is the front door of nearly every Linux server, yet the way most people configure it still looks like it did years ago: generate a key, copy it to the server, turn off passwords. Meanwhile OpenSSH itself has moved on considerably — weak algorithms have been removed, secure defaults have been tightened, and post-quantum signatures are already enabled out of the box.

This article walks through picking the right key type, generating keys, configuring both the client and the server, and then the parts that are usually skipped: constraint-bearing authorized_keys, SSH certificates, brute-force protection, key lifecycle and the post-quantum path.


Why passwords are no longer an option

Passwords over SSH have two inherent problems: they can be guessed, and although they travel over an encrypted channel, they still depend entirely on their own strength. It only takes one botnet sweeping port 22 for you to see thousands of login attempts per day.

The easy-to-miss part is that PasswordAuthentication still defaults to yes in sshd. A freshly installed server will keep accepting password logins even after you add a key, unless you turn it off explicitly. Key authentication inverts the model: the server holds a public key, the private key never leaves your machine, and an attacker has nothing to guess.


What has changed in OpenSSH recently

Before configuring anything, it helps to know which version you are running, because the defaults have shifted:

VersionNotable change
10.0 (04/2025)Removed the DSA signature algorithm (ssh-dss) entirely
10.1 (10/2025)Warns when a connection negotiates a non-post-quantum key exchange; added client-side WarnWeakCrypto
10.3 (04/2026)Certificates with an empty principals section are no longer treated as a wildcard
10.4 (07/2026)Experimental support for the hybrid post-quantum signature mldsa44-ed25519
10.5 (08/2026)restrict in authorized_keys is now applied correctly to tunnel forwarding too
10.6 (10/2026)Hybrid signature ssh-mldsa44-ed25519 enabled by default; WarnWeakCrypto for the server; default KDF rounds raised to 32; default RSA size raised to 3072 bits

Check your version and the algorithms it supports:

1
2
3
4
ssh -V
ssh -Q kex        # key exchange
ssh -Q key        # key types
ssh -Q sig        # signature algorithms

Choosing the right key type

Key typeWhen to use itNotes
ed25519Default for almost everythingFast, small keys, no dependency on NIST curves. The first choice
ed25519-skWhen the key must live on hardwarePrivate key stays inside a FIDO2 token and cannot be extracted
mldsa44-ed25519When you want to try post-quantum signaturesNew and not yet widely supported; only use it when both sides support it
rsaOnly when compatibility forces you toMust be at least 3072 bits; ssh-keygen now defaults to 3072
ecdsaAcceptable, but not idealDepends on NIST curves; no real advantage over Ed25519
dsaDo not useRemoved from OpenSSH as of 10.0

The surrounding ecosystem is tightening too: from October 14, 2026, every new RSA key uploaded to GitHub must be at least 3072 bits, and ssh-rsa using SHA-1 will be removed entirely on January 13, 2027 (with brownouts beforehand). If you still have old RSA keys in CI/CD, this is worth handling now.


Generating keys the right way

Create an Ed25519 key with more KDF rounds than the default, to slow attackers down if the private key is ever stolen:

1
ssh-keygen -t ed25519 -a 100 -C "hoangduong@workstation-01"
  • -a 100: number of KDF rounds (bcrypt_pbkdf). The current default is 32; raising it makes each passphrase guess dramatically slower for an attacker.
  • -C: a comment in user@machine form so you can tell later whose key it is. The comment has no security impact but a large operational one.
  • Always set a passphrase. A private key without a passphrase means anyone who copies the file can log in.

Record the key fingerprint, you will need it later when verifying host keys:

1
ssh-keygen -lf ~/.ssh/id_ed25519.pub

If you are forced to use RSA, specify the length explicitly rather than relying on the default:

1
ssh-keygen -t rsa -b 4096 -a 100 -C "compat-key-01"

Hardware-backed FIDO2 keys for sensitive environments

For production servers or high-privilege accounts, keeping the key on a hardware device beats keeping it as a file on disk. The private key is generated inside the token and cannot be extracted:

1
ssh-keygen -t ed25519-sk -O resident -O verify-required -C "yubikey-01"
  • -O resident: stores the key handle on the token so it can be used from multiple machines.
  • -O verify-required: every signature must be verified with a PIN or biometrics.
  • restrict and verify-required can also be enforced from the authorized_keys side.

Load resident keys from the token into the agent:

1
2
ssh-keygen -K
ssh-add -P     # skip PIN entry for tokens that do not require it

Getting the public key onto the server

The simplest route is ssh-copy-id, which writes the key to the right file with the right permissions:

1
ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@<server-host>

Then double-check permissions on the server — wrong permissions are the most common cause of publickey failures:

1
2
3
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chmod go-w ~                 # the home directory must not be group/other writable

sshd enables StrictModes yes by default: if the home directory, ~/.ssh or authorized_keys is writable by the group or by others, sshd will refuse to use that key. On SELinux systems, remember to relabel:

1
restorecon -R -v ~/.ssh

ssh-agent and the agent forwarding trap

The agent keeps decrypted private keys in memory so you do not have to re-enter the passphrase constantly:

1
2
eval "$(ssh-agent -s)"
ssh-add -t 8h ~/.ssh/id_ed25519     # expires automatically after 8 hours

On macOS, add the key to the Keychain with UseKeychain yes in ~/.ssh/config so the agent survives reboots.

Agent forwarding (ForwardAgent) is something you should avoid by default. When enabled, anyone with root on the destination host can use the forwarded agent socket to sign with your key — they cannot extract the private key, but they can use it. If you truly need forwarding, scope it to specific hosts rather than enabling it globally. Since OpenSSH 10.6, AgentSocketPath lets you control where the forwarded agent socket lives on the server.


Clean and safe client configuration

Put everything in ~/.ssh/config instead of typing long flag combinations every time:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Host prod-web-01
    HostName 203.0.113.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes
    AddKeysToAgent confirm

Host *
    HashKnownHosts yes
    StrictHostKeyChecking accept-new
    UpdateHostKeys yes
    ServerAliveInterval 60
    ServerAliveCountMax 3
  • IdentitiesOnly yes: use only the declared keys, so the agent does not offer a pile of unrelated keys and burn through the server’s MaxAuthTries.
  • AddKeysToAgent confirm: ask before loading a key into the agent, so keys are not used behind your back.
  • HashKnownHosts yes: obscure host names in known_hosts in case the file leaks. Note that the default is still no.
  • StrictHostKeyChecking accept-new: accept new host keys automatically but still refuse when a host key changes — a good balance for automation. For environments that demand strict control, use yes and manage known_hosts centrally.
  • UpdateHostKeys yes: lets the server advertise additional host keys after authentication, smoothing host key rotation.
  • ServerAliveInterval: detect dead connections instead of leaving sessions hanging.

A handy trick to see which keys will be tried, in order, without actually logging in (OpenSSH 10.5 and later):

1
ssh -Z deploy@<server-host>

Verifying host keys and known_hosts

The client must verify the server’s identity, otherwise strong encryption means nothing against a MITM. known_hosts supports two useful markers:

  • @cert-authority: a single line holding a CA key trusted for an entire domain, for example *.example.com.
  • @revoked: marks a key as revoked, so ssh always warns and never accepts it.
1
2
3
ssh-keygen -F prod-web-01        # look up a host key
ssh-keygen -R prod-web-01        # remove a stale host key when a server rotates its keys
ssh-keygen -H                    # hash the entire known_hosts file

On infrastructure with trustworthy DNS, you can verify through DNS with SSHFP:

1
VerifyHostKeyDNS ask

For emergency revocation, use a Key Revocation List (KRL) wired into sshd via RevokedKeys, or RevokedHostKeys on the client side.


Server-side hardening

A safe rule: never edit the main configuration file directly. On Debian/Ubuntu, sshd_config normally already contains Include /etc/ssh/sshd_config.d/*.conf, so create a separate drop-in file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf >/dev/null <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
PubkeyAuthentication yes
PermitEmptyPasswords no
AllowGroups ssh-users
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
AllowTcpForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
EOF

A quick explanation of the important lines:

DirectiveMeaning
PasswordAuthentication noDisable password logins entirely
KbdInteractiveAuthentication noDisable the keyboard-interactive channel (which defaults to yes)
PermitRootLogin prohibit-passwordRoot cannot log in with a password; a safe default, but worth stating explicitly
AllowGroups ssh-usersOnly the listed group may log in
MaxAuthTries 3Reduce the number of attempts before the connection is dropped
LoginGraceTime 30Shorten the window for unauthenticated connections
AllowTcpForwarding noTurn off forwarding if you do not need it

Validate the configuration before reloading — this step is mandatory:

1
2
3
sudo sshd -t                                   # syntax check
sudo sshd -T | grep -Ei 'passwordauth|permitrootlogin|allowgroups'
sudo systemctl reload ssh    # or sshd, depending on the distro

sshd -T prints the effective configuration after every drop-in and Match block has been applied. It is the only way to be certain you actually disabled what you meant to disable. On distros using systemd socket activation, use systemctl reload ssh.socket if the service is triggered by the socket.

Do not lock yourself out: keep an open SSH session while making changes, and open a second terminal to verify you can log in before closing the old one. For users who need an exception, use Match:

1
2
3
4
Match User backup-bot
    PasswordAuthentication no
    AllowTcpForwarding no
    PermitTTY no

authorized_keys is more than a list of keys

Each line in authorized_keys can carry constraints. This is the most underused tool in the SSH toolbox relative to its value:

OptionEffect
restrictDisables all forwarding, PTY allocation and ~/.ssh/rc; re-enable only what you need
from="..."Accept only from given IP/CIDR ranges or hostnames
command="..."Force a single command, ignoring whatever the client sent
expiry-time="..."The key expires automatically at a date/time
permitopen / permitlistenLimit the destinations of -L / -R forwarding
no-touch-required / verify-requiredFIDO2-specific constraints

Here is a key dedicated to a backup job, only usable from the internal range and forced to run one script:

1
restrict,from="198.51.100.0/24",command="/usr/local/bin/backup.sh" ssh-ed25519 AAAA... backup-bot

Important: force-command only forces the command, it does not disable forwarding automatically. That is why restrict should be the starting point for any key that does not need an interactive shell.


SSH certificates for larger fleets

Managing authorized_keys by hand does not scale. SSH certificates solve this by making every server trust a single CA, while user keys are signed by that CA with a short lifetime:

1
2
3
4
5
6
# On the CA machine
ssh-keygen -t ed25519 -f ~/.ssh/ca_user_key -C "ssh-ca"

# Sign a user certificate, valid for 8 hours
ssh-keygen -s ~/.ssh/ca_user_key -I "hoangduong@company" \
    -n deploy -V +8h ~/.ssh/id_ed25519.pub

On the server side you only need to trust the CA and declare which principals are valid:

1
2
TrustedUserCAKeys /etc/ssh/ca_user_key.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u

A CA can also be trusted directly inside authorized_keys with cert-authority,principals="deploy". The biggest win is that certificates expire on their own, so you no longer leave stale keys on hundreds of servers after people change roles. Host certificates (ssh-keygen -h) apply the same idea to host keys and make large-scale host key rotation manageable.


Brute-force protection and 2FA

If you ever deployed fail2ban purely to block SSH brute force, there is good news: sshd now has per-source penalties, enabled by default through PerSourcePenalties:

KeywordDefaultMeaning
authfail5sFailed authentication followed by disconnect
invaliduser5sAttempt with a non-existent user
noauth1sConnect and disconnect without attempting authentication
grace-exceeded10sExceeded LoginGraceTime
max10mMaximum accumulated penalty

You can tighten this further with PerSourceMaxStartups, which limits unauthenticated connections from a single IP. fail2ban is still useful for centralised logging or blocking at the firewall layer, but it is no longer your only line of defence.

For high-privilege accounts, enable a second factor through PAM (TOTP):

1
AuthenticationMethods publickey,keyboard-interactive

Note that AuthenticationMethods requires each listed method to be enabled accordingly — if you disabled KbdInteractiveAuthentication, re-enable it for the user group that needs 2FA rather than globally.


Preparing for post-quantum

SSH traffic today can be recorded and decrypted later. OpenSSH has already moved to hybrid post-quantum key exchange by default:

  • mlkem768x25519-sha256 is part of the default KexAlgorithms list.
  • Since 10.6, the hybrid signature ssh-mldsa44-ed25519 is enabled by default; keys created with the experimental 10.4 build must be regenerated because the algorithm name changed.
  • WarnWeakCrypto (default yes) logs when a connection uses a non-post-quantum key exchange, which helps you spot outdated clients.

Two operational notes:

1
2
ssh -Q kex | grep -i mlkem
ssh -o KexAlgorithms=mlkem768x25519-sha256,curve25519-sha256 deploy@<server-host>

If you must stay compatible with older clients, use + to append rather than replacing the default list. On compression: as of 10.6 OpenSSH disabled the LZ77 dictionary coder to patch a side channel in multiplexed sessions, so Compression is less effective now; if you need compression, doing it at the application layer works better.


Key lifecycle: rotation, revocation and backup

Keys are not a create-once-and-forget thing. A minimal process:

  • Inventory: use sshd -T to see the effective configuration, and lastlog/journalctl -u ssh to see who logged in and how.
  • Rotation: create the new key, add it first, verify you can log in, and only then remove the old one. Never remove before adding.
  • Revocation: delete the matching line in authorized_keys, or use a KRL at scale; with SSH certificates you simply stop signing or add the certificate to a KRL.
  • Backup: store the private key encrypted somewhere separate from your workstation. A lost private key cannot be recovered, only regenerated.
  • Audit old keys: any ssh-rsa key that does not use SHA-2, or is smaller than 3072 bits, should be replaced.

Deployment notes

  • Safe rollout order: add the key, verify key login works, and only then disable password authentication. Never the other way around.
  • Always keep an escape hatch: hold an open SSH session, and if possible a console/IPMI channel, before touching sshd_config.
  • Validate with sshd -t, then sshd -T before reloading; a drop-in under sshd_config.d only works if the main sshd_config has the Include directive.
  • Do not treat changing the SSH port as security. It only reduces scanner noise; disabling passwords and hardening are the real measures.
  • For automation/CI: use a dedicated key, constrain it with restrict,from=, and never embed a passphrase-less private key in a shared environment variable.
  • Avoid copying authorized_keys between machines when SSH certificates are available; every copy is another place you may forget to revoke.
  • On SELinux systems, always run restorecon -R -v ~/.ssh after creating ~/.ssh or restoring from backup.

Common troubleshooting

Permission denied (publickey) — run ssh -vvv to see whether the client is offering a key at all, then check the server side:

1
2
3
4
ssh -vvv deploy@<server-host>
sudo sshd -T | grep -Ei 'pubkey|authorizedkeysfile|strictmodes'
ls -ld ~ ~/.ssh; ls -l ~/.ssh/authorized_keys
sudo journalctl -u ssh -n 50

Most cases come down to: wrong file permissions, a group-writable home directory, the key not being in AuthorizedKeysFile, or an incorrect SELinux context.

REMOTE HOST IDENTIFICATION HAS CHANGED — the server’s host key changed (reinstall, or a MITM attempt). Verify the fingerprint with an administrator over a different channel, then:

1
ssh-keygen -R <server-host>

sshd -t fails after adding a drop-in — usually an option that is not allowed inside Match, or a misspelled directive. sshd -T with -C simulating a specific connection shows which configuration actually applies:

1
sudo sshd -T -C user=deploy,host=<server-host>,addr=203.0.113.10

Final thoughts

Secure SSH is not about one magic configuration line; it is about choosing the right key type, disabling passwords completely, constraining keys through authorized_keys, and moving to certificates once the number of machines grows. The post-quantum part is already handled at the key exchange layer by OpenSSH — your job is simply not to disable it for the sake of one old client.

Start by checking your current server: run sshd -T and look at what PasswordAuthentication and PermitRootLogin are set to. If they are still yes, that is worth fixing before you even finish reading this.

RESPONSES & DISCUSSION