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.
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:
| Version | Notable 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:
| |
Choosing the right key type
| Key type | When to use it | Notes |
|---|---|---|
ed25519 | Default for almost everything | Fast, small keys, no dependency on NIST curves. The first choice |
ed25519-sk | When the key must live on hardware | Private key stays inside a FIDO2 token and cannot be extracted |
mldsa44-ed25519 | When you want to try post-quantum signatures | New and not yet widely supported; only use it when both sides support it |
rsa | Only when compatibility forces you to | Must be at least 3072 bits; ssh-keygen now defaults to 3072 |
ecdsa | Acceptable, but not ideal | Depends on NIST curves; no real advantage over Ed25519 |
dsa | Do not use | Removed 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:
| |
-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 inuser@machineform 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:
| |
If you are forced to use RSA, specify the length explicitly rather than relying on the default:
| |
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:
| |
-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.restrictandverify-requiredcan also be enforced from theauthorized_keysside.
Load resident keys from the token into the agent:
| |
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:
| |
Then double-check permissions on the server — wrong permissions are the most common cause of publickey failures:
| |
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:
| |
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:
| |
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:
| |
IdentitiesOnly yes: use only the declared keys, so the agent does not offer a pile of unrelated keys and burn through the server’sMaxAuthTries.AddKeysToAgent confirm: ask before loading a key into the agent, so keys are not used behind your back.HashKnownHosts yes: obscure host names inknown_hostsin case the file leaks. Note that the default is stillno.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, useyesand manageknown_hostscentrally.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):
| |
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.
| |
On infrastructure with trustworthy DNS, you can verify through DNS with SSHFP:
| |
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:
| |
A quick explanation of the important lines:
| Directive | Meaning |
|---|---|
PasswordAuthentication no | Disable password logins entirely |
KbdInteractiveAuthentication no | Disable the keyboard-interactive channel (which defaults to yes) |
PermitRootLogin prohibit-password | Root cannot log in with a password; a safe default, but worth stating explicitly |
AllowGroups ssh-users | Only the listed group may log in |
MaxAuthTries 3 | Reduce the number of attempts before the connection is dropped |
LoginGraceTime 30 | Shorten the window for unauthenticated connections |
AllowTcpForwarding no | Turn off forwarding if you do not need it |
Validate the configuration before reloading — this step is mandatory:
| |
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:
| |
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:
| Option | Effect |
|---|---|
restrict | Disables 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 / permitlisten | Limit the destinations of -L / -R forwarding |
no-touch-required / verify-required | FIDO2-specific constraints |
Here is a key dedicated to a backup job, only usable from the internal range and forced to run one script:
| |
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:
| |
On the server side you only need to trust the CA and declare which principals are valid:
| |
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:
| Keyword | Default | Meaning |
|---|---|---|
authfail | 5s | Failed authentication followed by disconnect |
invaliduser | 5s | Attempt with a non-existent user |
noauth | 1s | Connect and disconnect without attempting authentication |
grace-exceeded | 10s | Exceeded LoginGraceTime |
max | 10m | Maximum 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):
| |
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-sha256is part of the defaultKexAlgorithmslist.- Since 10.6, the hybrid signature
ssh-mldsa44-ed25519is enabled by default; keys created with the experimental 10.4 build must be regenerated because the algorithm name changed. WarnWeakCrypto(defaultyes) logs when a connection uses a non-post-quantum key exchange, which helps you spot outdated clients.
Two operational notes:
| |
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 -Tto see the effective configuration, andlastlog/journalctl -u sshto 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-rsakey 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, thensshd -Tbefore reloading; a drop-in undersshd_config.donly works if the mainsshd_confighas theIncludedirective. - 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_keysbetween machines when SSH certificates are available; every copy is another place you may forget to revoke. - On SELinux systems, always run
restorecon -R -v ~/.sshafter creating~/.sshor 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:
| |
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:
| |
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:
| |
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.
