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
- Use Ed25519, or a hardware key (
ed25519-sk) if you have a FIDO2 key. - 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.
- Use one key per device. Laptop, desktop and CI server, each with its own.
- 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.
- Say which key to use for each server and don't forward your agent.
- Check the host key on the first connection and whenever it changes.
- Encrypt the disk: LUKS, FileVault or BitLocker.
- Review and revoke: the keys registered on GitHub and GitLab, and the servers'
authorized_keys.
A starter ~/.ssh/config
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 yesmakes 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 nois already the default, but writing it down keeps an earlier block from turning it on without you noticing.ProxyJump servermakesssh internalgo throughserverwithout taking your agent there.AddKeysToAgent 8hputs 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:
ssh -G server | grep -E '^(hostname|user|identityfile|identitiesonly|forwardagent|addkeystoagent) '
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
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/config ~/.ssh/authorized_keys
chmod 644 ~/.ssh/id_ed25519.pub
ls -la ~/.ssh
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
| File | Permission | Who 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:
echo $SSH_AUTH_SOCK
/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-agentasks 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:
ksshaskpasscan 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
keychainprogram: despite the name, it stores nothing. It's a script, in thekeychainpackage of most distributions, that reuses a singlessh-agentacross 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:
- Check whether the key has a passphrase, with the test from the other article.
- Add one. On a key without a passphrase,
ssh-keygen -ponly asks for the new one, twice:
ssh-keygen -p -f ~/.ssh/id_ed25519
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.
- Take the passphrase-less copy out of the agent, where it's still loaded:
ssh-add -D.gcr-ssh-agentoffers the keys in~/.sshagain on its own, now asking for the passphrase. - Use the key as usual, with a
git pushor anssh 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:
ssh-add -t 8h ~/.ssh/id_ed25519
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:
ssh-add -c -t 8h ~/.ssh/id_ed25519
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:
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:
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:
ssh-keygen -t ed25519-sk -O verify-required -C "[email protected]"
- The device asks for a touch on every use, by default.
-O verify-requiredalso 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_skisn'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/:
# /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:
sudo sshd -T | grep -E '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)'
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:
ssh-add --apple-use-keychain ~/.ssh/id_ed25519
And in ~/.ssh/config:
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:
ssh -V
Keys live in C:\Users\YOU\.ssh, or $env:USERPROFILE\.ssh. ssh-keygen is the same, and its default is Ed25519 too:
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:
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:
Get-Service ssh-agent | Set-Service -StartupType Automatic
Start-Service ssh-agent
And, as your user:
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, orssh-add -dwith 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:
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:
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:
- Confirmation on every use, with
AddKeysToAgent confirm 8hor the approval prompt of 1Password, Bitwarden or KeePassXC. Everygit pushfrom the AI agent opens a window on your screen, and you see what was about to happen. Denied, it gets theagent refused operationshown above. - A touch on a hardware key. A process can't press a physical button.
- 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.
- The production key out of reach, or behind confirmation, while an AI agent works in the same session.
- 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
~/.sshfolder.
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
- 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:
ssh-keygen -lf ~/.ssh/authorized_keys
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)
- Generate a new key and register it in place of the old one.
- 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 sshdoutside Debian and Ubuntu). On GitHub, your account's security log shows recent actions. - Review the
authorized_keysof 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
-skin 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 8horssh-add -t 8h) - Confirmation on every use, or a hardware key, if AI agents run commands in your terminal
-
IdentitiesOnly yesand oneIdentityFileper server - No agent forwarding;
ProxyJumpto go through a bastion - Host keys checked on the first connection and when they change
- Correct permissions (
chmod 600, oricaclson 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