Choose the Right Key Algorithm

In 2026, use Ed25519. It produces 256-bit keys based on elliptic curve cryptography, signs faster than RSA-4096, and generates a shorter public key that is easier to manage in authorized_keys files. RSA is still supported and still secure at 4096 bits, but there is no practical reason to prefer it for new deployments unless you are dealing with legacy systems that do not support Ed25519.

ECDSA (256-bit, NIST curve) is acceptable but Ed25519 is preferred because the NIST curves have documented controversy around their generation parameters. DSA is disabled in OpenSSH 7.0 and higher and should not be used.

For FIPS-compliant environments, RSA-4096 or ECDSA P-384 may be required. Check your organization's policy. Outside of FIPS, Ed25519 is the correct choice.

# Generate an Ed25519 key pair
ssh-keygen -t ed25519 -C "user@hostname-$(date +%Y%m%d)" -f ~/.ssh/id_ed25519

# If you need RSA for legacy compatibility
ssh-keygen -t rsa -b 4096 -C "user@hostname-$(date +%Y%m%d)" -f ~/.ssh/id_rsa

Generate the Key Pair Correctly

Run ssh-keygen as the user who will authenticate, not as root, unless root is the authenticating user. The -C flag sets a comment embedded in the public key. We use a format that includes the source hostname and date, which helps enormously when auditing authorized_keys files across many servers six months later.

Always set a passphrase. A key without a passphrase is a plaintext credential. If an attacker reads your ~/.ssh/id_ed25519 file, they have full access to every server that trusts it. The passphrase encrypts the private key on disk using AES-256-CBC (OpenSSH format). Use ssh-agent or a hardware token to avoid typing the passphrase repeatedly.

The output of a successful keygen looks like this - two files are created: the private key (id_ed25519, permissions must be 600) and the public key (id_ed25519.pub, permissions 644 are fine). If the private key permissions are wrong, ssh will refuse to use it.

# Verify permissions after generation
ls -la ~/.ssh/id_ed25519*
# Should show:
# -rw------- 1 user user 419 Jun 23 09:00 /home/user/.ssh/id_ed25519
# -rw-r--r-- 1 user user 107 Jun 23 09:00 /home/user/.ssh/id_ed25519.pub

# Fix permissions if wrong
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

Copy the Public Key to the Remote Server

The correct tool is ssh-copy-id. It handles the authorized_keys file creation, permissions, and duplicate checking automatically. Do not manually append keys unless you have a specific reason to.

ssh-copy-id creates ~/.ssh/ with permissions 700 and ~/.ssh/authorized_keys with permissions 600 if they do not exist. It appends the key if the file already exists. It will not add a duplicate if the key is already present.

If ssh-copy-id is not available (some minimal installations omit it), the manual method works but you must set permissions correctly yourself. The most common mistake we see is authorized_keys with permissions 644, which OpenSSH rejects when StrictModes is enabled (it is enabled by default).

For servers behind a bastion or with a non-standard port, pass the additional ssh options directly.

# Standard copy
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@192.168.1.100

# Non-standard port
ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 2222 user@192.168.1.100

# Manual method if ssh-copy-id is unavailable
cat ~/.ssh/id_ed25519.pub | ssh user@192.168.1.100 \
  'mkdir -p ~/.ssh && chmod 700 ~/.ssh && \
   cat >> ~/.ssh/authorized_keys && \
   chmod 600 ~/.ssh/authorized_keys'

# Verify the key was added
ssh user@192.168.1.100 'tail -1 ~/.ssh/authorized_keys'
// advertisement

Harden sshd_config

Copying the key is not enough. The default sshd_config on most distributions still permits password authentication as a fallback. Until you disable it, your server accepts both methods. An attacker can still brute-force the password even if keys are configured.

Edit /etc/ssh/sshd_config directly or, on systems using the include pattern (Debian 12, Ubuntu 22.04+), drop a file into /etc/ssh/sshd_config.d/ to avoid modifying the distribution-managed base config. We prefer the drop-in approach.

After editing, always run sshd -t to test the config before reloading. A syntax error in sshd_config with no active session to the server means you are locked out. Keep one existing SSH session open while you test. Do not close it until you have verified the new session works.

The critical directives to set are shown in the config block. PubkeyAuthentication yes is the default in recent OpenSSH builds, but we set it explicitly. PasswordAuthentication no is the key change. PermitRootLogin prohibit-password allows root login only with keys, which some automation requires. If no automation runs as root, set it to no.

# Drop-in config for Ubuntu 22.04+ and Debian 12+
cat > /etc/ssh/sshd_config.d/10-hardened.conf << 'EOF'
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
PermitRootLogin prohibit-password
AuthorizedKeysFile .ssh/authorized_keys
X11Forwarding no
AllowTcpForwarding no
MaxAuthTries 3
LoginGraceTime 20
EOF

# Test config before reloading
sshd -t && echo "Config OK"

# Reload (not restart - preserves existing sessions)
systemctl reload sshd

Restrict Keys in authorized_keys with Options

authorized_keys supports per-key options that restrict what a key can do. This is one of the most underused features in SSH. For a key that only needs to run a specific command (backups, monitoring agents, deployment scripts), lock it down with the command option. For keys used from a specific IP range, add from= restrictions.

The options go at the start of the authorized_keys line, before the key type. You can stack multiple options separated by commas.

For automated deployment or CI/CD keys, the combination of command, no-pty, no-agent-forwarding, and no-x11-forwarding creates a key that can only execute one specific command from one source IP. Even if the private key is compromised, the attacker cannot get an interactive shell.

If you are building automation pipelines around SSH and want to go further with AI-assisted orchestration, tools like taskbotshub.ai can help manage key rotation and deployment workflows programmatically, though the underlying SSH hardening described here still applies regardless of what tooling sits on top.

# Example authorized_keys entries with options

# Backup key: only allows rsync, only from specific IP, no interactive use
from="10.0.1.50",command="/usr/bin/rsync --server --daemon .",no-pty,no-agent-forwarding,no-x11-forwarding,no-port-forwarding ssh-ed25519 AAAA... backup-key-20260101

# Monitoring key: read-only command, no shell
command="/usr/local/bin/collect-metrics.sh",no-pty,no-agent-forwarding ssh-ed25519 AAAA... monitoring-20260101

# Standard admin key with IP restriction
from="203.0.113.0/24",no-x11-forwarding ssh-ed25519 AAAA... admin-workstation-20260623

Use ssh-agent to Manage Passphrase Entry

A passphrase-protected key that requires manual entry on every connection is a passphrase that gets removed. Use ssh-agent to hold decrypted keys in memory for the duration of a session.

On modern Linux desktops with systemd, the user ssh-agent socket is often already running. On headless servers or in tmux sessions, you start it manually. The key stays in agent memory until you remove it, the agent dies, or you reboot.

For servers that need to forward authentication (a bastion that SSHes further), ForwardAgent yes in your client ~/.ssh/config enables agent forwarding. However, be careful: agent forwarding to a server you do not fully trust exposes your keys to the server's root user. Use it only to hosts you control.

For long-lived keys on high-security systems, ssh-add -t sets a time limit in seconds after which the agent forgets the key automatically. We use 28800 (8 hours) on workstations.

# Start ssh-agent and add key
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519

# Add key with 8-hour expiry
ssh-add -t 28800 ~/.ssh/id_ed25519

# List keys currently in agent
ssh-add -l

# Remove a specific key from agent
ssh-add -d ~/.ssh/id_ed25519

# Remove all keys from agent
ssh-add -D

# Persistent agent in ~/.bashrc or ~/.bash_profile
if [ -z "$SSH_AUTH_SOCK" ]; then
  eval "$(ssh-agent -s)" > /dev/null
  ssh-add ~/.ssh/id_ed25519 2>/dev/null
fi
// advertisement

Configure ~/.ssh/config for Multi-Host Environments

Typing ssh -i ~/.ssh/id_ed25519 -p 2222 -l deploy 203.0.113.10 for every connection is how typos happen. The client config at ~/.ssh/config maps short aliases to full connection parameters.

The config file uses a Host block pattern. Settings in a Host block apply to connections matching that pattern. A Host * block at the end sets defaults for all connections. More specific blocks earlier in the file take precedence over general blocks later.

For environments with many servers, we use a naming convention in the Host alias that mirrors the server's role and environment - for example prod-web-01, staging-db-02. If you are also thinking about consistent naming conventions across your infrastructure or domains, the approach described at nicename.me for project and domain naming translates well to SSH host alias conventions: short, descriptive, no ambiguity.

The ServerAliveInterval and ServerAliveCountMax settings prevent dropped idle connections, which is critical when working over mobile or unreliable links.

# ~/.ssh/config
# Permissions must be 600: chmod 600 ~/.ssh/config

# Production web server
Host prod-web-01
  HostName 203.0.113.10
  User deploy
  Port 2222
  IdentityFile ~/.ssh/id_ed25519
  IdentitiesOnly yes

# Staging bastion with agent forwarding
Host staging-bastion
  HostName 198.51.100.5
  User admin
  IdentityFile ~/.ssh/id_ed25519
  ForwardAgent yes

# Jump through bastion to internal host
Host staging-internal-*
  User deploy
  IdentityFile ~/.ssh/id_ed25519
  ProxyJump staging-bastion

# Global defaults
Host *
  ServerAliveInterval 60
  ServerAliveCountMax 3
  AddKeysToAgent yes
  IdentitiesOnly yes
  HashKnownHosts yes

Key Rotation and Auditing authorized_keys

SSH keys do not expire automatically. A key generated in 2019 and never revoked is still valid unless you remove it from authorized_keys. This is the most common SSH security debt we find during audits.

Establish a rotation policy. We recommend annual rotation for admin keys, quarterly for service/automation keys. For each rotation, generate a new key pair, add the new public key to authorized_keys alongside the old one, update all consumers of the old key to use the new private key, then remove the old public key from authorized_keys.

For auditing, iterate over all authorized_keys files across your fleet. The script below pulls the key fingerprints and comments from every authorized_keys file it can find, which makes it straightforward to identify old or unexpected entries.

The comment field (the last field in a public key line) is your only human-readable audit trail. This is why we embed hostname and date in the -C flag during key generation. A comment that says user@laptop tells you nothing. A comment that says alice@workstation-alice-20240115 tells you exactly when and where it was created.

# Audit authorized_keys on a single server - list all key fingerprints
while IFS= read -r line; do
  [[ "$line" =~ ^#|^$ ]] && continue
  echo "$line" | ssh-keygen -l -f /dev/stdin 2>/dev/null
done < /root/.ssh/authorized_keys

# Find all authorized_keys files on the system
find /home /root /etc -name "authorized_keys" 2>/dev/null

# Count entries per user
for f in $(find /home /root -name authorized_keys 2>/dev/null); do
  echo "$(wc -l < $f) keys in $f"
done

# Remove a specific key by fingerprint (requires manual edit or use of this pattern)
# First identify the fingerprint:
ssh-keygen -l -f ~/.ssh/id_ed25519.pub
# Output: 256 SHA256:abc123... user@host (ED25519)

Centralized Key Management at Scale

Managing authorized_keys files manually across more than 20 servers does not scale. At that point you need a centralized approach. Three viable options exist: AuthorizedKeysCommand (sshd queries an external program for keys), LDAP-backed SSH keys, or a secrets manager like HashiCorp Vault with the SSH secrets engine.

AuthorizedKeysCommand is the lowest-friction option. It lets sshd call an external script or binary to retrieve authorized keys for a user at login time, rather than reading from the file. The script can query a database, an LDAP directory, or any other source.

The sshd_config AuthorizedKeysCommand directive takes the full path to the script and must point to a file owned by root with no write permissions for group or other. The command receives the username as an argument and must print authorized_keys-format lines to stdout.

For teams running infrastructure-as-code pipelines, integrating key distribution into your existing configuration management (Ansible, Puppet, Chef) is often the pragmatic path. A simple Ansible task that copies the contents of a keys/ directory in your repo to authorized_keys on all managed hosts, run on every deploy, gives you version-controlled key management with audit history in git.

# sshd_config: use external command for key lookup
AuthorizedKeysCommand /usr/local/bin/get-ssh-keys.sh %u
AuthorizedKeysCommandUser nobody

# Permissions required on the script
chown root:root /usr/local/bin/get-ssh-keys.sh
chmod 755 /usr/local/bin/get-ssh-keys.sh

# Example Ansible task for centralized key distribution
# In your playbook:
# - name: Deploy authorized keys
#   ansible.posix.authorized_key:
#     user: "{{ item.user }}"
#     key: "{{ item.key }}"
#     state: present
#     exclusive: true  # Removes keys NOT in this list
#   loop: "{{ ssh_authorized_keys }}"

# Vault SSH one-time certificate issuance (sign a key, not store it)
vault write ssh/sign/admin public_key=@~/.ssh/id_ed25519.pub
// advertisement

Verify and Troubleshoot the Full Chain

When key authentication fails, the default error message from ssh is not helpful. Use -v (verbose) flags to get the actual failure reason. Three -v flags gives the maximum detail from the client side.

On the server side, the logs are in /var/log/auth.log (Debian/Ubuntu) or /var/log/secure (RHEL/CentOS). The sshd process logs at the level set by LogLevel in sshd_config. The default is INFO, which shows successful and failed auth attempts but not the detail of why a key was rejected. Set LogLevel VERBOSE during debugging to see key fingerprints being tested.

Common failure causes in order of frequency from our experience: wrong permissions on ~/.ssh or authorized_keys, the public key in authorized_keys does not match the private key being offered (copy-paste truncation is the usual cause), SELinux or AppArmor blocking sshd from reading the file, and home directory permissions too permissive (sshd rejects authorized_keys if the home directory is world-writable).

# Client-side verbose debug
ssh -vvv user@host 2>&1 | grep -E 'Offering|Trying|denied|succeeded|key'

# Server-side: watch auth log in real time
tail -f /var/log/auth.log | grep sshd

# Temporarily increase sshd log level without restart
# Add to sshd_config.d/debug.conf, reload, remove after debugging
echo 'LogLevel VERBOSE' > /etc/ssh/sshd_config.d/99-debug.conf
systemctl reload sshd

# Check home directory permissions (must not be world-writable)
ls -ld ~
ls -la ~/.ssh/
ls -la ~/.ssh/authorized_keys

# SELinux: restore context if files were copied in from outside
restorecon -Rv ~/.ssh/

# Test sshd config is valid after any change
sshd -t