In short. SSH key authentication replaces a password with a file pair: the private key never leaves your machine, the public key goes into ~/.ssh/authorized_keys on the server. Generate a key with ssh-keygen -t ed25519, install it with ssh-copy-id, set 700 on the directory and 600 on the file, verify login in a second session, and only then disable PasswordAuthentication.
How SSH key authentication actually works
A password is a shared secret: it exists on both sides and can be brute-forced. An SSH key works differently. You generate two files:
id_ed25519— the private key. It never leaves your machine. It is your access.id_ed25519.pub— the public key. Safe to copy, paste into a chat, or install on a dozen servers.
On connection the server sends a random challenge. The client signs it with the private key, and the server verifies the signature against the public key stored in authorized_keys. The private key is never transmitted — in any form. So traffic interception, a compromised jump host, or a leaked provider password database gives an attacker nothing.
The practical effect: the bots hammering root:123456 on port 22 around the clock stop being a threat category. Not "less likely to succeed" — mathematically unable to.
A private key is not "the server password", it is the access itself. Never send id_ed25519 over a messenger, drop it into shared storage, commit it to a repository, or bake it into a Docker image. If it leaks, the key is revoked — you do not "change the password".

ed25519 vs RSA: which SSH key type to choose
Short answer: ed25519. It landed in OpenSSH 6.5 and has been the sane default ever since.
- Strength. Ed25519 provides roughly 128 bits of security at a 256-bit key length. Matching that with RSA requires at least 3072 bits.
- Size. An ed25519 public key is a single short line that fits in a terminal. An RSA 4096 public key is a multi-line wall of base64 that is painful to copy by hand.
- Speed. Signing and verification are noticeably faster than RSA 4096 — measurable on CI and mass deployments.
- Fewer ways to get it wrong. No curve to pick, no signature strength that silently depends on the quality of the client's random number generator.
Keep an RSA key only as a compatibility fallback: legacy network gear, appliances, or a Git host that does not speak Ed25519.
| Key type | Strength | Public key size | Support | Verdict |
|---|---|---|---|---|
| ed25519 | ~128 bits, modern Curve25519 | One line, ~80 characters | OpenSSH 6.5+, GitHub, GitLab, every current distribution | Default choice |
| ed25519-sk | Same, plus hardware presence check | One line, slightly longer | Needs a FIDO2 token and recent OpenSSH on both ends | Best option for admin keys |
| RSA 4096 | Adequate at 3072 bits and above; 1024 and 2048 are weak | Several lines, hundreds of characters | Practically universal, including legacy | Compatibility fallback |
| ECDSA (nistp256/384/521) | Nominally comparable to ed25519 | Short | Wide | Works, but no reason to pick it today |
| DSA (ssh-dss) | Capped at 1024 bits, considered obsolete | Medium | Disabled by default since OpenSSH 7.0, removed from recent releases | Do not use |
One separate trap: the ssh-rsa signature algorithm (SHA-1 based) is disabled by default in recent OpenSSH. The RSA key itself stays valid — the problem is the legacy signature algorithm. If an old RSA key suddenly stops being accepted after a client or server upgrade, this is almost always the cause.
Generating an SSH key with ssh-keygen
ssh-keygen -t ed25519 -a 100 -C "alice@laptop-prod" -f ~/.ssh/id_ed25519_prod
Flag by flag:
-t ed25519— key type.-a 100— KDF rounds used when encrypting the private key with your passphrase. More rounds make offline cracking of a stolen file more expensive; 100 keeps the unlock delay unnoticeable.-C "alice@laptop-prod"— comment. It is appended to the public key and ends up inauthorized_keys. Six months later this comment is how you decide whether a line can be deleted. Write the human and the machine, not "my key".-f ~/.ssh/id_ed25519_prod— path. Distinct filenames per environment beat oneid_ed25519for everything.
Passphrase: yes or no
Yes. The passphrase encrypts the private key at rest. Without it, a stolen laptop, a forgotten home directory backup, or access to a synced folder hands an attacker working logins to every server you touch.
The "typing it every time is annoying" objection is solved by ssh-agent — the passphrase is entered once per session. A passphrase-less key is acceptable only for machine workflows (CI, deploys, backups), and such a key must be separate and tightly restricted.
You can add or change a passphrase without reissuing the key:
ssh-keygen -p -a 100 -f ~/.ssh/id_ed25519_prod
Check the fingerprint to be sure you are looking at the right file:
ssh-keygen -lf ~/.ssh/id_ed25519_prod.pub
# 256 SHA256:kx3Y... alice@laptop-prod (ED25519)
Creating an SSH key on Windows
Windows 10 and 11 ship a built-in OpenSSH client — no extra software required. The same ssh-keygen -t ed25519 -C "..." works in PowerShell, and keys land in C:\Users\Name\.ssh. The agent runs as a Windows service:
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent
ssh-add $env:USERPROFILE\.ssh\id_ed25519
PuTTY is a separate world. It does not read the OpenSSH format and uses its own .ppk. Keys are created in PuTTYgen and the agent is called Pageant. An existing OpenSSH key can be imported into PuTTYgen and saved as .ppk; the reverse conversion is available through the OpenSSH export option. Either way, what goes onto the server is the single-line public form ssh-ed25519 AAAA... comment — not the entire PuTTYgen text block with its headers.
How to add an SSH key to a server
The easy path is ssh-copy-id. It creates the directory, appends the key and fixes permissions:
ssh-copy-id -i ~/.ssh/id_ed25519_prod.pub -p 22 deploy@203.0.113.10
Always pass the .pub file explicitly. Without -i the tool pulls keys from the agent, and a key you did not intend may end up on the server.
If ssh-copy-id is unavailable (typical on plain macOS and on Windows), one command does the same job:
cat ~/.ssh/id_ed25519_prod.pub | ssh deploy@203.0.113.10 \
"umask 077; mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
umask 077 matters: it guarantees the directory and file are created with correct permissions immediately instead of relying on a follow-up chmod. Note the >> — a single > wipes every existing key, including your colleagues'.
Manual installation and verification
If the only way in is the provider's web console, log in as the right user and add the key by hand:
mkdir -p ~/.ssh
chmod 700 ~/.ssh
nano ~/.ssh/authorized_keys # paste one complete line
chmod 600 ~/.ssh/authorized_keys
chown -R "$USER:$USER" ~/.ssh
A public key is exactly one line. A line break in the middle breaks it: the server sees two garbage entries instead of one working key. Verify after pasting:
ssh-keygen -lf ~/.ssh/authorized_keys
The command prints one line per valid key. Fewer lines than keys you added means a wrapped paste.
The key belongs to the user you will log in as./root/.ssh/authorized_keysand/home/deploy/.ssh/authorized_keysare different files. A classic mistake is installing the key into root's home undersudoand then wondering whyssh deploy@hoststill asks for a password.
File permissions: the number one cause of Permission denied (publickey)
SSH runs with StrictModes yes by default: if permissions on the directory or files are too loose, the server silently ignores the key and behaves as if it does not exist. The client only ever sees Permission denied (publickey). This accounts for roughly half of all "my key does not work" cases.
| Path | Mode | Owner | What breaks |
|---|---|---|---|
/home/user | 750 or 700 (not group/world writable) | user | The whole .ssh directory is rejected |
~/.ssh | 700 | user | Keys are not read at all |
~/.ssh/authorized_keys | 600 | user | Key silently ignored |
~/.ssh/id_ed25519 (client) | 600 | user | UNPROTECTED PRIVATE KEY FILE, client refuses the key |
~/.ssh/config (client) | 600 | user | Bad owner or permissions |
One-shot fix on the server:
chmod go-w ~
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
chown -R "$(id -un):$(id -gn)" ~/.ssh
restorecon -Rv ~/.ssh 2>/dev/null || true
The last line matters on SELinux systems (RHEL, Rocky, AlmaLinux, Fedora): a .ssh directory restored from an archive or copied out of /tmp carries the wrong security context, and sshd cannot read it even with formally correct modes. On Debian and Ubuntu the command is simply absent and is skipped safely.
Using ~/.ssh/config and ssh-agent
Past three servers, long commands with -i, -p and full usernames become unbearable. The fix is a client config with host aliases.
# ~/.ssh/config
Host prod-web
HostName 203.0.113.10
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519_prod
IdentitiesOnly yes
Host db-internal
HostName 10.0.5.20
User admin
IdentityFile ~/.ssh/id_ed25519_prod
ProxyJump prod-web
Host *
ServerAliveInterval 30
ServerAliveCountMax 3
HashKnownHosts yes
Now ssh prod-web does everything the long command did, and ssh db-internal transparently routes through the bastion with no manual tunnelling.
The critical setting is IdentitiesOnly yes. Without it the client offers every key in the agent, one after another. With six keys loaded and MaxAuthTries 6 on the server, attempts run out before the right key is reached and you get Too many authentication failures.
ssh-agent: type the passphrase once
# Linux: start the agent if the session did not
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519_prod # asks for the passphrase once
ssh-add -l # which keys are loaded
ssh-add -D # unload everything
A good habit is bounding key lifetime in the agent: ssh-add -t 4h ~/.ssh/id_ed25519_prod. When the timer expires the key unloads itself, so a forgotten unlocked session stops being permanent access.
On macOS the agent integrates with Keychain: ssh-add --apple-use-keychain ~/.ssh/id_ed25519_prod stores the passphrase, and UseKeychain yes in ~/.ssh/config makes the client retrieve it automatically.
Do not putForwardAgent yesunderHost *. Agent forwarding lets root on the intermediate host use your keys for as long as the session is open. If you genuinely need it, enable it for one specific bastion — or better, replace it withProxyJump, which does not expose the agent at all.

Disabling password login, root login and changing the SSH port
Keys alone close nothing: while PasswordAuthentication yes is in place, bots keep guessing. The benefit appears only once password login is off.
Edit /etc/ssh/sshd_config:
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
PermitEmptyPasswords no
MaxAuthTries 3
LoginGraceTime 20
AllowUsers deploy admin
What matters here:
KbdInteractiveAuthentication nois mandatory alongsidePasswordAuthentication no. Otherwise, on PAM-based systems, passwords remain reachable through the keyboard-interactive method and you get a false sense of a closed door.PermitRootLogin prohibit-passwordkeeps root reachable by key (some automation needs it) while blocking passwords. If root is not needed at all, useno.AllowUsersis an allow-list. Even a leaked service key cannot be used to log in as somebody else.
The Include and sshd_config.d trap
Modern distributions start sshd_config with Include /etc/ssh/sshd_config.d/*.conf. For sshd the first match wins, so a value set in an included file overrides anything you write further down the main file. Cloud images frequently ship a drop-in that re-enables password authentication.
So check the effective configuration, not the file you edited:
sudo sshd -T | grep -Ei 'passwordauth|permitroot|pubkeyauth|kbdinteractive|^port'
sudo grep -r -n 'PasswordAuthentication' /etc/ssh/
sshd -T prints the resolved values with all includes applied. If it says passwordauthentication yes while your file says no, hunt down the overriding drop-in.
sshd -t and the second-session rule
A typo in sshd_config means the daemon will not come back after a restart. If SSH is the only way into the box, you have just lost it until you reach the provider console.
# 1. Syntax check BEFORE restarting
sudo sshd -t
# 2. Only if the check is silent — apply
sudo systemctl reload ssh || sudo systemctl reload sshd
# 3. In a SEPARATE terminal window — verify login
ssh -o PreferredAuthentications=publickey deploy@203.0.113.10
Keep your current SSH session open until a new one connects successfully. reload does not drop established connections — that session is your rollback path. Close both windows without testing and your only remaining options are the provider console or a rescue boot.
Changing the SSH port: what it buys and what it does not
Moving SSH off 22 is popular advice, and it helps with exactly one thing: mass scanners that only hit 22 stop flooding auth.log, which makes the log readable again.
What a port change does not buy: security. A full port sweep finds SSH in seconds, and the banner reveals the daemon version regardless of port number. If password login is still enabled, an obscure port will not save you. Treat it as log hygiene, not a control. For the wider picture on what should be exposed at all, see the write-up on open port security.
# /etc/ssh/sshd_config
Port 2222
Three things are routinely forgotten afterwards:
- Open the port in the firewall before restarting:
sudo ufw allow 2222/tcporsudo firewall-cmd --permanent --add-port=2222/tcp && sudo firewall-cmd --reload. - On SELinux systems, label the port for SSH:
sudo semanage port -a -t ssh_port_t -p tcp 2222. Without it the daemon cannot bind. - Check whether SSH is socket-activated. On recent Ubuntu the daemon starts through
ssh.socket, and thePortdirective insshd_configis ignored — the port lives in the socket override.
# If ssh.socket is active, the port is set here
systemctl is-enabled ssh.socket
sudo systemctl edit ssh.socket
# in the editor:
# [Socket]
# ListenStream=
# ListenStream=2222
The empty ListenStream= line is required: it clears the inherited value of 22, otherwise the daemon listens on both ports.
SSH keys for Git: GitHub, GitLab and deploy keys
Git over SSH uses the same mechanics. A dedicated key for Git hosting is good practice: compromising it does not hand over your production servers.
ssh-keygen -t ed25519 -C "alice@github" -f ~/.ssh/id_ed25519_github
cat ~/.ssh/id_ed25519_github.pub # paste this into your account settings
# verify ("successfully authenticated" is the expected reply — there is no shell)
ssh -T git@github.com
ssh -T git@gitlab.com
Pinning the key to the host in the config removes all guesswork:
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github
IdentitiesOnly yes
Host gitlab-work
HostName gitlab.com
User git
IdentityFile ~/.ssh/id_ed25519_work
IdentitiesOnly yes
The second block solves the common "two accounts on one host" problem: clone as git clone git@gitlab-work:group/project.git and the client picks the right key.
Why a deploy key must be separate
A developer's personal key dropped onto a server for git pull grants that server access to every repository the human can reach. One compromised web server then becomes a full source-code breach.
The correct model: one key per server and repository, read-only, without a passphrase (automation cannot type one), with an explicit review date. On the server side, scope it directly in authorized_keys:
# a single line in ~/.ssh/authorized_keys
restrict,from="203.0.113.0/24",command="/usr/local/bin/deploy.sh" ssh-ed25519 AAAAC3Nza... ci@runner
restrictdisables everything optional at once: port forwarding, agent forwarding, X11, PTY allocation.from=pins the key to a source network.command=forces a single command: whatever the client asks for, only this runs.

How to remove an SSH key and revoke access
Revocation is not "change the password". A key keeps working as long as its line physically sits in an authorized_keys file on at least one server.
# 1. Find every place the key comment or fingerprint appears
sudo grep -rn "alice@laptop" /home/*/.ssh/authorized_keys /root/.ssh/authorized_keys 2>/dev/null
# 2. Remove the line (back it up first)
sudo cp ~/.ssh/authorized_keys ~/.ssh/authorized_keys.bak
sudo sed -i '/alice@laptop/d' /home/deploy/.ssh/authorized_keys
# 3. Confirm the remaining list is what you expect
ssh-keygen -lf /home/deploy/.ssh/authorized_keys
Check every location, not just the obvious one:
- home directories of all users, including service accounts and root;
- the legacy
~/.ssh/authorized_keys2, if it survived from older systems; - non-standard paths:
sudo sshd -T | grep authorizedkeysfile— some setups store keys under/etc/ssh/authorized_keys/%u; - keys in CI, provider panels, Git hosting (both account keys and deploy keys), golden images and VM templates;
- live sessions: deleting a line does not terminate an established connection — review
whoand usepkill -u username sshd.
Across a fleet, a central denylist is easier. The RevokedKeys directive in sshd_config points at a file of revoked public keys, which are rejected even if a stale line survives somewhere in an authorized_keys.
Separately, "remove an SSH key" sometimes means the server entry in the client's known_hosts:
ssh-keygen -R 203.0.113.10
ssh-keygen -R "[203.0.113.10]:2222" # for a non-standard port
Debugging with ssh -vvv: the common errors
The universal first step is the verbose client log:
ssh -vvv -o PreferredAuthentications=publickey -i ~/.ssh/id_ed25519_prod deploy@203.0.113.10
Look for Offering public key: and what follows it. If the server keeps replying Authentications that can continue: publickey, the key was offered and rejected — the problem is server-side. If the client never offers the file at all, the problem is local: wrong path, wrong permissions, key not in the agent.
Server side (requires a second way in):
sudo journalctl -u ssh -f
sudo tail -f /var/log/auth.log # Debian/Ubuntu
sudo tail -f /var/log/secure # RHEL family
Permission denied (publickey)
The most common and least informative error. Work through it in order:
- Permissions:
~not group-writable,~/.sshat 700,authorized_keysat 600, owner matching the login user. - Wrong user: the key sits in
deploy's home while you connect asroot. - Wrong key: compare
ssh-keygen -lf key.pubwithssh-keygen -lf authorized_keys— fingerprints must match. - Broken line: a paste that wrapped in the middle of the key.
- User filtered out by
AllowUsers/AllowGroups, or the account is locked (passwd -S deployreportingL). - Wrong SELinux context on
.ssh.
Bad owner or permissions on /home/user/.ssh/config
The client refuses to read a config writable by group or others. One command fixes it: chmod 600 ~/.ssh/config. The same logic applies to the directory itself.
WARNING: UNPROTECTED PRIVATE KEY FILE!
The private key is readable by someone other than the owner. Run chmod 600 ~/.ssh/id_ed25519. A frequent source is copying keys from a USB stick or a Windows partition where POSIX permissions are not preserved.
Host key verification failed
The server fingerprint no longer matches the one stored in known_hosts. Legitimate causes: OS reinstall, migration, restore from backup, IP change. Illegitimate cause: an active man-in-the-middle. Confirm the new fingerprint through a second channel (provider console, internal documentation) before clearing the old entry with ssh-keygen -R. Turning on StrictHostKeyChecking no just legalises server substitution.
Too many authentication failures
The agent offered more keys than MaxAuthTries permits. Fix it with IdentitiesOnly yes and an explicit IdentityFile per host.
no matching host key type found / ssh-rsa
An old SHA-1 RSA key meeting a modern OpenSSH. The right fix is reissuing the key as ed25519. The temporary workaround is allowing the algorithm for that one host with PubkeyAcceptedAlgorithms +ssh-rsa — which postpones the problem rather than solving it.
Connection refused / Connection timed out
Not an authentication issue at all. refused means the port is closed or the daemon is down: check systemctl status ssh. timed out means a firewall or cloud security group is dropping traffic. Both must be tested from outside, not from the machine itself.

How to verify your setup
After the edits, look at the server from the outside. Local ss -tlnp and systemctl status only prove the daemon is alive; they say nothing about what the internet sees.
- Is the SSH port reachable? Scan the host with the port scanner. If 22 (or your custom port) answers the whole world, the next question is whether it should. The stronger setup restricts SSH by source IP at the firewall or cloud security group. How to read scan results is covered in the guide on checking open ports.
- General server hygiene. Run the domain through the security check: headers, TLS, exposed configuration artefacts. SSH is one door; locking it does nothing about an open admin panel or a missing HSTS header. The perimeter-wide checklist lives in the article on web server hardening.
- Effective config check.
sudo sshd -T | grep -Ei 'passwordauth|permitroot|kbdinteractive'is the only reliable source of truth about what actually applied. - An actual password attempt.
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password deploy@hostshould be refused without ever prompting. If you get a password prompt, password login is still on.
For admin keys there is a level above this: a hardware token (ssh-keygen -t ed25519-sk) requires a physical touch on every connection, so a stolen key file is useless on its own. The reasoning mirrors modern web authentication — see the breakdowns of two-factor authentication and passkeys versus classic 2FA.
Frequently asked questions
Can I use one SSH key for all servers?
Technically yes, and for a personal admin key it is reasonable: one key, revoked in one place. Trouble starts when the same key also ends up in CI, in a container and on a colleague's laptop. The rule is one key per identity — you, a specific CI runner, a specific service. Not one key for everyone.
What do I do if a private key leaks?
Immediately remove the matching public key from authorized_keys on every server and from every Git hosting account, terminate that user's active sessions, then generate a new pair and roll it out. Changing the passphrase on a leaked key is pointless: the attacker already has the file and can crack the passphrase offline at leisure.
Do I still need fail2ban if passwords are disabled?
Its value drops but does not vanish. Key guessing is futile, yet fail2ban trims log noise, reduces load from mass scanners and catches probing of other services. As the sole protection for SSH it is unnecessary — disabled passwords are stronger.
Why does the key work for one user but not another?
Almost always ownership or permissions. A .ssh directory copied from another user with cp -r under sudo keeps the old owner, and sshd rejects it. Run ls -ld ~ ~/.ssh ~/.ssh/authorized_keys — the owner must match the login user.
Must I disable root SSH login entirely?
At minimum, disable password login for root (PermitRootLogin prohibit-password). Blocking it completely and working through a normal user with sudo is better: you get personalised audit trails showing who ran a command instead of an anonymous root.
My key stopped working after a server rebuild — why?
A rebuild wipes authorized_keys along with the home directory, and the server gets a new host key. The first is fixed by reinstalling the key, the second by ssh-keygen -R plus confirming the new fingerprint through the provider console.
Checklist
- Key generated as
ed25519, with a meaningful comment and a passphrase. - The private key was never copied anywhere: no chat, no repository, no container image.
- Public key installed for the correct user, as a single line, fingerprints matching.
- Permissions set: home directory not group-writable,
~/.sshat 700,authorized_keysat 600, correct owner. - Client
~/.ssh/configand private keys at 600. - Key login verified in a separate session before touching the server config.
PasswordAuthentication noandKbdInteractiveAuthentication noconfirmed throughsshd -T, not just written to a file.sshd_config.dchecked for overriding drop-ins.sudo sshd -trun before restarting, with a second session held open.- If the port changed: opened in the firewall, labelled for SELinux,
ssh.socketaccounted for. - Separate keys issued for Git and deployment; the deploy key scoped with
restrictandcommand=. - A revocation procedure exists: where to look, who executes it, how the result is verified.
- Port verified from outside with the port scanner, the perimeter with the security check.