Keeping your SSH keys safe on Linux, macOS and Windows

· 16 min read

  • linux
  • security
  • ssh

Before you start

This article is the practical half of SSH keys from the inside: how they work, the types and the risks. That one has the reason behind each recommendation: what the passphrase protects, the risk of agent forwarding, why offering every key to every server is bad. This one has what to do, system by system.

The Linux commands were run with OpenSSH 10.2p1 on Ubuntu 26.04, and the outputs are real, with the username swapped for a generic user. The macOS and Windows ones follow the documentation from Apple, Microsoft and the projects mentioned, but weren't captured here: compare each step with what your system shows.

What applies to every system

  1. Use Ed25519, or a hardware key (ed25519-sk) if you have a FIDO2 key.
  2. Put a passphrase on every key for personal use. Without one, the file is the whole credential. The other article shows how to check yours.
  3. Use one key per device. Laptop, desktop and CI server, each with its own.
  4. Let the agent hold the key for a limited time, like a workday, and ask for confirmation on every use if AI agents run commands in your terminal.
  5. Say which key to use for each server and don't forward your agent.
  6. Check the host key on the first connection and whenever it changes.
  7. Encrypt the disk: LUKS, FileVault or BitLocker.
  8. Review and revoke: the keys registered on GitHub and GitLab, and the servers' authorized_keys.

A starter ~/.ssh/config

text
Host github.com
  User git
  IdentityFile ~/.ssh/id_ed25519

Host server
  HostName 203.0.113.10
  User deploy
  IdentityFile ~/.ssh/id_ed25519

Host internal
  HostName 10.0.0.5
  ProxyJump server

Host *
  IdentitiesOnly yes
  ForwardAgent no
  AddKeysToAgent 8h
  • IdentitiesOnly yes makes the client offer only the key configured for that server (or the default ones, if none is), instead of every key in the agent.
  • ForwardAgent no is already the default, but writing it down keeps an earlier block from turning it on without you noticing.
  • ProxyJump server makes ssh internal go through server without taking your agent there.
  • AddKeysToAgent 8h puts the key in the agent the first time you type the passphrase, and takes it out after eight hours: you type it once per workday, and the key doesn't spend the night in the agent.

Host * goes at the end because, in ssh_config, the first value found for each option wins: specific blocks come first, and the generic one fills in what's missing. To see the final result for a host, use ssh -G:

bash
ssh -G server | grep -E '^(hostname|user|identityfile|identitiesonly|forwardagent|addkeystoagent) '
output
user deploy
hostname 203.0.113.10
identitiesonly yes
identityfile ~/.ssh/id_ed25519
addkeystoagent 28800
forwardagent no

One key per device, and no backups

Unlike an encryption key, losing an SSH key doesn't make you lose data, only access. So instead of backing up the key (and spreading more copies of it), register a second key in the places that matter: another computer's key, or a hardware key kept in a drawer. If the laptop is stolen, you remove just its key from everywhere and get in with the other one.

For servers, make sure you have a second way in: your provider's web console, for example. Getting locked out is the real risk for anyone with a single key.

An encrypted disk

The passphrase protects the key file, but the disk holds more: known_hosts (where you connect), config, your shell history. With an encrypted disk, a lost laptop that's turned off gives none of that away. Linux: LUKS, offered by the installer of most distributions. macOS: FileVault. Windows: BitLocker, or "Device encryption" on Home editions.

Linux

Permissions

bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/config ~/.ssh/authorized_keys
chmod 644 ~/.ssh/id_ed25519.pub
ls -la ~/.ssh
output
total 20
drwx------ 2 user user 140 Oct 15 13:14 .
drwxrwxr-x 3 user user  60 Oct 15 13:14 ..
-rw------- 1 user user 101 Oct 15 13:14 authorized_keys
-rw------- 1 user user  28 Oct 15 13:14 config
-rw------- 1 user user 464 Oct 15 13:14 id_ed25519
-rw-r--r-- 1 user user 101 Oct 15 13:14 id_ed25519.pub
-rw-r--r-- 1 user user  37 Oct 15 13:14 known_hosts
FilePermissionWho gets access
~/.ssh/drwx------ (700)Only you can enter the folder
id_ed25519, config, authorized_keys-rw------- (600)Only you read and write
id_ed25519.pub, known_hosts-rw-r--r-- (644)You write, everyone reads

The three groups of letters are what the owner, the group and others can do: r read, w write, x execute (for a folder, enter). The other article explains how to read them in detail, and shows the "UNPROTECTED PRIVATE KEY FILE" warning the client gives when the private key is too open.

known_hosts holds no secret, but it shows where you connect. If the machine has other users, a chmod 600 on it doesn't hurt either. On the server, sshd refuses an authorized_keys file, or a ~/.ssh folder, that other users can write to.

Which agent is running

Almost every Linux desktop already has an agent. Find out which one:

bash
echo $SSH_AUTH_SOCK
output
/run/user/1000/gcr/ssh

That's GNOME's (gcr-ssh-agent), which uses OpenSSH's ssh-agent underneath and asks for the passphrase in a window. On KDE, it's usually ssh-agent with ksshaskpass. If the variable is empty, there's no agent, and you can start one for the terminal session with eval "$(ssh-agent -s)".

The passphrase in the system keyring

On macOS, the Keychain stores the passphrase and puts the key back in the agent on its own after you log in. On Linux you can get the same in a few ways, all with the trade-off from the macOS section: you stop typing the passphrase, and it's protected by your login password, or your vault's, instead.

  • GNOME: when gcr-ssh-agent asks for the passphrase, the window has the option Automatically unlock this key whenever I'm logged in. Checked, the passphrase goes into GNOME Keyring's login keyring, which is unlocked along with your session. To see or delete it later, use the Passwords and Keys app (Seahorse).
  • KDE: ksshaskpass can store the passphrase in KWallet, which is also unlocked at login.
  • KeePassXC: its SSH agent integration adds the key when you unlock the database and removes it when you lock it, with the option to ask for confirmation on every use. See password managers as the agent.
  • The keychain program: despite the name, it stores nothing. It's a script, in the keychain package of most distributions, that reuses a single ssh-agent across terminals, so the passphrase is asked once per boot. It's for servers and WSL, where there's no desktop with an agent.

What if the key has no passphrase

Then there's nothing to store: the agent loads a key without a passphrase without asking anything, and the keyring never comes into play. That's exactly the case where the file alone is already the credential. The way out is to add a passphrase first and let the keyring store it:

  1. Check whether the key has a passphrase, with the test from the other article.
  2. Add one. On a key without a passphrase, ssh-keygen -p only asks for the new one, twice:
bash
ssh-keygen -p -f ~/.ssh/id_ed25519
output
Key has comment '[email protected]'
Enter new passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved with the new passphrase.

The key stays the same, it's just stored encrypted on disk from now on, so nothing needs to change on GitHub or the servers.

  1. Take the passphrase-less copy out of the agent, where it's still loaded: ssh-add -D. gcr-ssh-agent offers the keys in ~/.ssh again on its own, now asking for the passphrase.
  2. Use the key as usual, with a git push or an ssh server. GNOME shows the window asking for the passphrase: type it and check Automatically unlock this key whenever I'm logged in. From then on, it comes from the keyring.

The window only shows up when the agent itself needs the passphrase. If you run ssh-add in the terminal, the passphrase is asked right there, and the option to store it doesn't appear. Also keep the .pub next to the key: that's how gcr-ssh-agent finds the keys in ~/.ssh. On KDE and with KeePassXC, step 2 is the same; after that, ksshaskpass or the vault stores the passphrase.

With the passphrase in a keyring, the agent's lifetime loses its bite: the key comes back on its own on the next use. What keeps protecting you during use is confirmation, below.

Lifetime and confirmation

The starter config's AddKeysToAgent 8h takes care of the lifetime. By hand, the equivalent is:

bash
ssh-add -t 8h ~/.ssh/id_ed25519
output
Identity added: /home/user/.ssh/id_ed25519 ([email protected])
Lifetime set to 08:00:00

Why eight hours, and not one: the lifetime protects the moment you forget the key loaded, like a computer left on overnight. While you're using the computer, it protects nothing, and an hour only makes you type the passphrase several times a day. A workday is the middle ground: you type it in the morning, and the key doesn't spend the night in the agent. What protects you during use is confirmation, or a touch on a hardware key.

To ask for confirmation on every use:

bash
ssh-add -c -t 8h ~/.ssh/id_ed25519
output
Identity added: /home/user/.ssh/id_ed25519 ([email protected])
Lifetime set to 08:00:00
The user must confirm each use of the key

Every signature opens a window asking whether you allow it. Deny it, and whoever asked gets this:

output
sign_and_send_pubkey: signing failed for ED25519 "[email protected]" from agent: agent refused operation
user@localhost: Permission denied (publickey).

The window comes from an ssh-askpass program: on GNOME, gcr-ssh-agent ships its own; on KDE, it's ksshaskpass. Without one, confirmation can't happen and every use is denied, so on servers and WSL without a graphical interface stick to the lifetime. In the config, the equivalent is AddKeysToAgent confirm 8h.

To clear everything from the agent when you step away from the computer:

bash
ssh-add -D

Hardware keys (FIDO2)

With a FIDO2 key, like a YubiKey (series 5) or a Nitrokey 3, the private key is generated inside the device and never leaves it. OpenSSH on the major distributions already ships with support:

bash
ssh-keygen -t ed25519-sk -O verify-required -C "[email protected]"
  • The device asks for a touch on every use, by default.
  • -O verify-required also asks for the device's PIN on every use, so a stolen device isn't enough on its own.
  • The file generated at ~/.ssh/id_ed25519_sk isn't the key, just a reference to it: without the device, it's useless.

If you switch between computers, -O resident stores that reference on the device itself, and ssh-keygen -K retrieves it on any machine. The manual points out the trade-off: with everything on the device, whoever steals it is closer to using it, which is why resident keys usually require a PIN to be set.

I don't have a FIDO2 device in this lab, so the outputs in this section weren't captured. Some older keys don't support Ed25519: in that case, use -t ecdsa-sk.

If you also run the server

Once all your keys are working, turn off password logins on the server. Create a file in /etc/ssh/sshd_config.d/:

text
# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no

The name starts with 10- on purpose. In sshd_config the first value found also wins, the files in that folder are read in alphabetical order, and cloud images often ship a 50-cloud-init.conf with PasswordAuthentication yes. Check the effective value with sshd -T. In a lab with both files:

bash
sudo sshd -T | grep -E '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)'
output
permitrootlogin prohibit-password
passwordauthentication no
kbdinteractiveauthentication no

prohibit-password is the default: root can only log in with a key. If you never log in as root, change it to PermitRootLogin no.

Validate the syntax with sudo sshd -t and restart the service (sudo systemctl restart ssh on Debian and Ubuntu, sshd on Fedora and Arch). Keep the current session open and test a new connection from another terminal before leaving: if something went wrong, that session is how you fix it.

macOS

The passphrase in the Keychain

macOS already ships OpenSSH and an agent started by the system. Apple's own integration stores the passphrase in the Keychain:

bash
ssh-add --apple-use-keychain ~/.ssh/id_ed25519

And in ~/.ssh/config:

text
Host *
  IgnoreUnknown UseKeychain
  UseKeychain yes
  AddKeysToAgent yes

With that, the passphrase lives in the Keychain, which is unlocked when you log in, and the key goes back into the agent on its own after a restart. It's convenient, but it trades the passphrase for your macOS account password: whoever has your session open can use the key. Lock the screen when you step away.

IgnoreUnknown UseKeychain is for when you also use Homebrew's OpenSSH: UseKeychain only exists in Apple's OpenSSH, and Homebrew's rejects the whole config with "Bad configuration option" without that line.

Keys in the Secure Enclave

Macs with Apple silicon or the T2 chip have the Secure Enclave, a separate chip that keys can't be extracted from. Secretive is an open source app that creates SSH keys in there and works as an agent, with Touch ID on every use if you want. It's the equivalent of a hardware key without buying a device, with one difference: the key is tied to that Mac, so it's that device's key, and you need another for the rest.

The Secure Enclave only generates ECDSA P-256 keys, not Ed25519. That's fine: ECDSA's weakness is the random number in each signature, and the chip itself generates that number.

FIDO2 keys on macOS

The OpenSSH that ships with macOS is built without FIDO key support, and ssh-keygen -t ed25519-sk fails. The most common route is installing Homebrew's OpenSSH (brew install openssh), which includes that support, and checking with which ssh and ssh -V which of the two is being used. Secretive is usually the simpler option on a Mac.

FileVault

In System Settings > Privacy & Security > FileVault. The Mac's setup assistant offers to turn it on, but it's worth checking.

Windows

OpenSSH is already there

Windows 10 (since version 1809) and Windows 11 already ship the OpenSSH client. In PowerShell:

powershell
ssh -V

Keys live in C:\Users\YOU\.ssh, or $env:USERPROFILE\.ssh. ssh-keygen is the same, and its default is Ed25519 too:

powershell
ssh-keygen -t ed25519 -C "[email protected]"

Permissions

On Windows, permissions are ACLs, and Windows' OpenSSH also refuses a private key that other users can read. To make the file accessible only to you:

powershell
icacls $env:USERPROFILE\.ssh\id_ed25519 /inheritance:r
icacls $env:USERPROFILE\.ssh\id_ed25519 /grant:r "$($env:USERNAME):F"

The first command removes the permissions inherited from the folder; the second gives full control only to your user.

The ssh-agent service

Windows' agent is a service, and it ships disabled. In PowerShell as administrator:

powershell
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent

And, as your user:

powershell
ssh-add $env:USERPROFILE\.ssh\id_ed25519

Here Windows is quite different from Linux and macOS: the agent stores the key persistently, tied to your Windows account, and it's still there after a restart. Microsoft's documentation even suggests deleting the key file after adding it. In practice, you type the passphrase once, and from then on the key's protection is your Windows account's protection. So:

  • Protect the account: Windows Hello or a strong password, and lock the screen when you step away.
  • Use BitLocker.
  • Remove keys you no longer use with ssh-add -D, or ssh-add -d with the file.
  • For a more sensitive key, prefer a hardware key or a password manager as the agent (below).

Git for Windows

Git for Windows ships its own ssh.exe, which doesn't talk to Windows' ssh-agent service. The symptom is Git asking for the passphrase every time, even with the key in the agent. Point Git at the system's OpenSSH:

powershell
git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"

PuTTY

If you use PuTTY, or programs that bundle it (FileZilla, WinSCP, TortoiseGit), update: versions 0.68 to 0.80 leaked ECDSA P-521 keys (CVE-2024-31497). If you used a P-521 key with those versions, replace the key, because updating doesn't undo the signatures already made. PuTTY uses the .ppk format and its own agent, Pageant. PuTTYgen converts keys between the PuTTY and OpenSSH formats.

FIDO2 keys on Windows

Windows' OpenSSH uses the system's own WebAuthn API, so physical FIDO2 keys work without installing anything:

powershell
ssh-keygen -t ed25519-sk -O verify-required -C "[email protected]"

Windows shows its own window asking for the PIN and the touch. Using the computer's Windows Hello as the authenticator, instead of a physical device, is flaky: there are reports of the window replacing Windows Hello with "phone or USB key" after updates.

WSL

WSL is a separate Linux, with its own ~/.ssh: follow the Linux section in there. Don't keep keys under /mnt/c/...: by default, files on the Windows disk show up in WSL with open permissions, and ssh refuses to use them.

AI agents and your keys

AI agents that work in your terminal, like Claude Code, Codex CLI or editors' agent modes, run commands as your user and inherit your environment variables. That includes SSH_AUTH_SOCK: if your key is in the agent, a git push, an ssh server or a signed commit they run works without asking anything. It's the same risk as agent forwarding: nobody copies the key, but it gets used without you seeing. And an AI agent can be led into it by text it read, like a README, an issue or a page with hidden instructions (prompt injection).

That's no reason to take your keys out of the agent, but it is a reason to decide what a command you didn't type can do:

  1. Confirmation on every use, with AddKeysToAgent confirm 8h or the approval prompt of 1Password, Bitwarden or KeePassXC. Every git push from the AI agent opens a window on your screen, and you see what was about to happen. Denied, it gets the agent refused operation shown above.
  2. A touch on a hardware key. A process can't press a physical button.
  3. Separate, limited keys for what the AI agent does on its own. On GitHub, a deploy key gives access to a single repository, and a fine-grained token, used over HTTPS, can be limited to a few repositories and permissions. If it's misused, the damage stays there.
  4. The production key out of reach, or behind confirmation, while an AI agent works in the same session.
  5. Isolation. In a container or a VM, the AI agent only sees what you mount in there: don't mount your agent's socket or the ~/.ssh folder.

A combination that works well: your personal key with confirm 8h and, if the AI agent needs to push on its own, a deploy key restricted to that repository.

Password managers as the agent

1Password, Bitwarden and KeePassXC can work as an SSH agent: the key stays encrypted in the vault, and it's only available while the vault is unlocked. 1Password and Bitwarden also ask for approval on every use, with Touch ID, Windows Hello or the master password. They work on all three systems.

The trade-off: the vault syncs across your devices, so the same key ends up on all of them, the opposite of "one key per device". For most people, having the key protected by the vault and approved on every use is worth it. If you'd rather keep one key per device, create one key for each device inside the vault.

If a key leaks

  1. Remove the public key from everywhere it gets in: GitHub, GitLab and every server's authorized_keys. To find the line, list the file's fingerprints and compare them with the leaked key's:
bash
ssh-keygen -lf ~/.ssh/authorized_keys
output
256 SHA256:4+ja/K0i3L7pStIZYZVOnEvEd0YEFPjedY/2GlUWgFA [email protected] (ED25519)
256 SHA256:cycriPjeX1MyKaDUPvNH4ySmfg8RVZe+EGnV/EImFnQ [email protected] (ED25519)
3072 SHA256:ypI93OhP6BnAPzWA7Ut6fzAxERVrO8R1Ot19RxPG204 [email protected] (RSA)
256 SHA256:uhpYBP/1S7pAB7Agqm4u9GbKMzEld7i7qIWH5Su8PH0 [email protected] (ECDSA)
  1. Generate a new key and register it in place of the old one.
  2. Look for whether it was used. On servers, every login is logged with the key's fingerprint (journalctl -u ssh | grep 'Accepted publickey', or -u sshd outside Debian and Ubuntu). On GitHub, your account's security log shows recent actions.
  3. Review the authorized_keys of the servers it could get into. Whoever got in with a stolen key may have left another key there, to come back later.

Checklist

  • Ed25519 keys (or -sk in hardware); no DSA, no RSA under 3072 bits
  • A passphrase on every key for personal use (check with ssh-keygen -y -P '')
  • One key per device, and a second key registered in the places that matter
  • An agent with a lifetime (AddKeysToAgent 8h or ssh-add -t 8h)
  • Confirmation on every use, or a hardware key, if AI agents run commands in your terminal
  • IdentitiesOnly yes and one IdentityFile per server
  • No agent forwarding; ProxyJump to go through a bastion
  • Host keys checked on the first connection and when they change
  • Correct permissions (chmod 600, or icacls on Windows)
  • An encrypted disk (LUKS, FileVault or BitLocker)
  • Keys reviewed on GitHub, GitLab and the servers' authorized_keys
  • Password logins turned off on the servers you run
  • PuTTY and friends on version 0.81 or newer
  • A plan for a leaked key: remove, replace, check the logs

Sources