Skip to main content
this section: |

Migrating SSH keys from 1Password to Bitwarden

blog ~14 m

featured

This guide explains how to:

  1. Get your SSH keys out of 1Password
  2. Store them as SSH keys in Bitwarden
  3. Turn on the Bitwarden SSH agent
  4. Point ssh and git at Bitwarden
  5. Use different keys for different GitHub repos on the same host

Allow 30–45 minutes.

This is a follow‑on to Migrating from 1password to Bitwarden . That guide moved your passwords. This one moves your keys.


Prerequisites

  • Bitwarden Desktop app, version 2024.8.0 or newer — the SSH agent lives in the desktop app, not the browser extension.
  • 1Password still installed, with your SSH keys in it.
  • An ssh client (built in to macOS, Linux and modern Windows).

⚠️ Important ⚠️: An SSH private key is exactly as sensitive as a master password. Anyone who copies it can log in as you. Treat every export file like it is on fire.


1. Understand what moves

In 1Password an SSH key is a single item that holds a private key, a public key and a fingerprint. Bitwarden has the same item type, so a key becomes one SSH key entry in your vault.

There is no clean CSV path for SSH keys — the 1Password CSV export does not carry the private key in a form Bitwarden imports. So we move keys one at a time, by hand. If you only have two or three keys this is quick and it is the safest way.


2. Export one key from 1Password

Do this for each SSH key you own.

  1. Open 1Password and unlock your vault.
  2. Find the SSH key item.
  3. Click the private key field and choose Export and then Copy private key in OpenSSH format. (the exact wording depends on desktop version).
  4. Also note the name you used, e.g. GitHub – personal, GitHub – workco.

⚠️ Important

If you save a file rather than copying, that file holds your private key in plain text.

Do not: email it · upload it to cloud storage · leave it in Downloads.


3. Add the key to Bitwarden

  1. Open the Bitwarden Desktop app.
  2. Click + New → SSH key.
  3. Give it the same clear name you used in 1Password.
  4. Import from Clipboard or paste the private key into the private key field (wording changes depending on desktop version).
  5. Bitwarden fills in the public key and fingerprint for you automatically.
  6. Check the start and end of the fingerprint match 1Password
  7. Option: add a reminder of the expiry date of the key in the Notes field
  8. Save.

Repeat for every key. When you are done, your vault holds all your SSH keys and 1Password holds nothing you still need.

Tip: generate new keys here too

You do not have to import. In the New → SSH key dialog Bitwarden can generate a fresh Ed25519 or RSA key. This is a good moment to rotate any key that has been sitting in 1Password for years — generate a new one, add the public key to the service, then delete the old one.


4. Turn on the Bitwarden SSH agent

The SSH agent is what actually hands your keys to ssh when you connect, so you never put a private key on disk again.

  1. In the Bitwarden Desktop app open Settings.
  2. Find SSH agent and enable Use Bitwarden as your SSH agent.
  3. Leave the app running — the agent only works while the desktop app is open and unlocked.

When something needs a key, Bitwarden pops up and asks you to approve the use. That approval prompt is the whole point: your key never leaves the vault unattended.

The agent listens on a socket:

macOS / Linux

1
~/.bitwarden-ssh-agent.sock

Windows

1
\\.\pipe\openssh-ssh-agent

Windows uses the standard OpenSSH named pipe, so most tools find it with no extra config.

Note: turn off the 1Password SSH agent

If you used 1Password as your SSH agent, turn it off now — otherwise two agents fight over the same socket and ssh may keep offering keys from 1Password instead of Bitwarden.

  1. Open 1Password → Settings (or Preferences).
  2. Go to Developer.
  3. Uncheck Use the SSH agent.

Then check whether 1Password left a line in ~/.ssh/config:

Host *
    IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"

If a Host * block points at 1Password, delete it (or comment it out) — it sends every host back to 1Password and overrides the SSH_AUTH_SOCK you set in the next step. Also remove any SSH_AUTH_SOCK export for 1Password from your ~/.zshrc / ~/.bashrc.

Only the 1Password block goes. A Host * block is a perfectly good thing to have — in section 5 you will add one of your own pointing at Bitwarden, and it is what makes ssh work in processes that never read your shell config.


5. Point ssh at Bitwarden

Tell your shell to use Bitwarden’s socket by setting SSH_AUTH_SOCK.

macOS / Linux

Add this to ~/.zshrc or ~/.bashrc:

1
export SSH_AUTH_SOCK="$HOME/.bitwarden-ssh-agent.sock"

Then open a new terminal and check:

1
ssh-add -l

You should see your keys listed (and Bitwarden may ask you to approve).

Windows (CMD / PowerShell)

On native Windows the named pipe is the default, so git and ssh from CMD or PowerShell usually just work — nothing to set.

Windows + WSL

WSL is the awkward one. Bitwarden runs on the Windows side and only exposes the Windows named pipe — there is no ~/.bitwarden-ssh-agent.sock inside your Linux distro. If you copy the macOS/Linux line above into WSL it points at a file that does not exist, and every connection fails with Permission denied (publickey).

The fix is a small bridge: npiperelayđź”— (a tiny Windows .exe) talks to the pipe, and socat (in WSL) exposes it as a normal Unix socket that ssh understands.

1. Install socat inside WSL:

1
sudo apt-get update && sudo apt-get install -y socat

2. Get npiperelay.exe on the Windows side. Either:

3. Bridge it — add this to ~/.bashrc (or ~/.zshrc), editing the path to match where you put the .exe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# bitwarden ssh agent - bridge WSL to the Windows named pipe via npiperelay + socat
export SSH_AUTH_SOCK="$HOME/.ssh/bw-agent.sock"
_bw_npiperelay="/mnt/c/Users/<you>/bin/npiperelay.exe"

if command -v socat >/dev/null 2>&1 && [ -x "$_bw_npiperelay" ]; then
  ssh-add -l >/dev/null 2>&1
  # 0 = agent has keys, 1 = agent reachable but locked/empty, 2 = no agent there.
  # Only 2 means the relay is dead: rebuilding on 1 kills a working relay and
  # leaks it, which is what happens on every cold boot before Bitwarden unlocks.
  if [ $? -eq 2 ]; then
    mkdir -p "$(dirname "$SSH_AUTH_SOCK")"
    # Serialise: several shells starting at once must not each spawn a relay.
    # flock releases when fd 9 closes, so a killed shell cannot wedge it.
    ( flock -n 9 || exit 0
      # re-test inside the lock - the winner may have just fixed it for us
      ssh-add -l >/dev/null 2>&1; [ $? -ne 2 ] && exit 0
      # Reap relays orphaned by an earlier rm -f before unlinking the path again.
      # Match on comm, not pkill -f: the pattern appears in the command line of
      # anything that greps for it, and pkill -f would kill that too.
      for _p in $(pgrep -f "UNIX-LISTEN:$SSH_AUTH_SOCK" 2>/dev/null); do
        [ "$(cat /proc/$_p/comm 2>/dev/null)" = "socat" ] && kill "$_p" 2>/dev/null
      done
      rm -f "$SSH_AUTH_SOCK"
      (setsid socat UNIX-LISTEN:"$SSH_AUTH_SOCK",fork \
         EXEC:"$_bw_npiperelay -ei -s //./pipe/openssh-ssh-agent",nofork 9>&- &) >/dev/null 2>&1
    ) 9>"$HOME/.ssh/.bw-agent.lock"
  fi
fi

The guard looks fussy. It is fussy because the obvious version leaks relay processes, and you will not notice until you go looking:

  • Ask the agent, do not test for the file. A relay that has died still leaves its socket file on disk, so [ -e "$SSH_AUTH_SOCK" ] says “fine” while every git call fails. Only ssh-add -l tells you the truth.
  • Rebuild on exit 2 only. ssh-add -l returns 0 with keys, 1 when the agent is reachable but locked or empty, and 2 when it cannot be reached. Cold boot hits state 1 — Bitwarden is not unlocked yet — so a ! test rebuilds a relay that was never broken, and every terminal you open leaks another one.
  • rm -f orphans whatever is listening. Unlinking the path does not stop the socat bound to it; it keeps running forever, holding a socket nothing can reach. So reap first, then unlink.
  • Take a lock. Open a VSCode window and four shells start at once, all see “no agent”, all build a relay. One wins the path, three leak.

To check for leaks on a machine that has been up a while — you want to see 1:

1
pgrep -x socat | wc -l

4. Reload and check:

1
2
source ~/.bashrc
ssh-add -l          # should list your keys; Bitwarden may pop up to approve

Windows + WSL + VSCode

Your terminal works, ssh -T git@github.com works — and then VSCode’s Source Control panel still fails with Permission denied (publickey).

VSCode’s Git extension does not run through your shell. It spawns git from the extension‑host process, which never sources ~/.bashrc, so it has no SSH_AUTH_SOCK and — once you have moved your private keys off disk — no identity to fall back on either. Everything you did above is invisible to it.

That needs two fixes, because there are two separate problems: the extension host cannot find the agent, and at login nothing has started it.

1. Let ssh find the agent without an environment variable. ssh always reads ~/.ssh/config, whoever launched it, so put the socket there. Add this at the end of ~/.ssh/config:

# Find the agent even when SSH_AUTH_SOCK is unset - the VSCode extension host,
# cron jobs and systemd units never source ~/.bashrc.
Host *
    IdentityAgent ~/.ssh/bw-agent.sock

It goes at the end because ssh_config is first‑match‑wins: a Host * block at the top would mask the specific host blocks you write in section 7.

2. Start the relay at login, not just in interactive shells. The VSCode WSL server sources ~/.vscode-server/server-env-setup before it starts, so the extension host inherits whatever that file sets up. Create it with the same guard you put in ~/.bashrc above — copy the block verbatim, it is safe to run twice:

1
2
3
4
5
# Sourced by the VSCode WSL server before it starts, so the extension host -
# and therefore the Git extension's `git` calls - inherit the SSH agent.
# Keep this fast and non-interactive.

# ... the same bitwarden relay guard as ~/.bashrc ...

Then fully restart the WSL server — reloading the window is not enough:

1
wsl --shutdown          # from Windows PowerShell

Reopen VSCode and check that the extension host really has it:

1
2
3
4
# in a VSCode integrated terminal
echo $SSH_AUTH_SOCK     # $HOME/.ssh/bw-agent.sock
ssh-add -l              # your Bitwarden keys
pgrep -x socat | wc -l  # 1 - not one per terminal you have opened

Confirm the config half works on its own, with the variable out of the way:

1
env -u SSH_AUTH_SOCK ssh -G github.com | grep identityagent

If Source Control still fails, turn on the extension’s own log — Output panel → Git — and read the git fetch line it printed. It will name the host it could not authenticate to, which is usually a host you never gave a block in section 7.


6. Test it

⚠️ Important — do this first ⚠️

Move any old private keys out of ~/.ssh before you test. If id_ed25519, id_rsa, a github-personal private key, etc. are still sitting on disk, ssh will happily use those and everything will “work” — but you will have no idea whether Bitwarden’s agent did anything or you just got lucky with a leftover file.

1
2
mkdir -p ~/.ssh/pre-bitwarden
mv ~/.ssh/id_* ~/.ssh/pre-bitwarden/ 2>/dev/null   # keep .pub files? see note

⚠️ Keep your *.pub files — section 7 needs them to pick the right key. Only move the private keys (the ones with no extension). Once the agent is proven working you can delete the private key backups.

Now, with no private keys on disk, any successful connection must be coming from Bitwarden. Try a service you use, for example GitHub:

Try a service you use, for example GitHub:

1
ssh -T git@github.com

Bitwarden should pop up asking you to approve the key. Approve it, and you should see your welcome message. No passphrase prompt, no key file on disk. Done.


7. Multiple keys for one host

This is the part that trips everyone up.

Say you have one key for your personal GitHub account and a separate deploy key for a work repo — both live on github.com. When ssh connects it offers keys to the agent in turn, and GitHub authenticates you as whichever key it recognises first. So you push to the work repo and GitHub says “permission denied” or, worse, authenticates you as the wrong account.

The fix is to give each key its own host alias in ~/.ssh/config and force ssh to use only that one key.

The trick is IdentitiesOnly yes together with an IdentityFile pointing at the public key. That public key does not unlock anything — it just tells ssh which of the agent’s keys to offer, instead of trying them all.

# ~/.ssh/config

# Personal account
Host github-personal
    HostName github.com
    User git
    IdentityFile ~/.ssh/github-personal.pub
    IdentitiesOnly yes

# Work repo deploy key
Host github-workco
    HostName github.com
    User git
    IdentityFile ~/.ssh/github-workco.pub
    IdentitiesOnly yes

# Keep this LAST - first match wins, so a Host * at the top would mask the blocks above
Host *
    IdentityAgent ~/.ssh/bw-agent.sock

There is no IdentityAgent line in the per‑host blocks — the single Host * block at the bottom covers them all. Set the path once, there and only there:

  • macOS / Linux → ~/.bitwarden-ssh-agent.sock
  • WSL → ~/.ssh/bw-agent.sock, the bridged socket from section 5, not the macOS path
  • Windows CMD / PowerShell → leave IdentityAgent out entirely, the named pipe is default

You still need the public key files on disk. Grab each one from its Bitwarden SSH key item (the public key field) and save it:

1
2
3
# paste the public key from Bitwarden into each file
nano ~/.ssh/github-personal.pub
nano ~/.ssh/github-workco.pub

Public keys are not secret, so keeping them on disk is fine.

Now use the alias instead of github.com in your git remotes:

1
2
3
4
5
# personal project
git clone git@github-personal:me/my-dotfiles.git

# work project — uses the deploy key
git clone git@github-workco:workco/the-product.git

For a repo you already cloned, just repoint the remote:

1
git remote set-url origin git@github-workco:workco/the-product.git

Test each alias:

1
2
ssh -T git@github-personal
ssh -T git@github-workco

Each should approve one specific key in Bitwarden and greet you as the right account.

Do this for single-key hosts too

Aliases are for two keys on one host. But a host you never gave a block to at all gets IdentitiesOnly no and is offered every key in your vault, in turn. Ten keys in Bitwarden and GitHub’s limit of six tries means Too many authentication failures before it ever reaches the right one — on a host that worked fine when you had three keys.

So give every host you use a block with its own .pub and IdentitiesOnly yes, even when it only has one key. If the host needs only one key you do not need an alias — use the real hostname:

Host gitlab.com
    HostName gitlab.com
    User git
    IdentityFile ~/.ssh/gitlab.pub
    IdentitiesOnly yes

Check what any host will actually offer:

1
ssh -G github.com | grep -E 'identitiesonly|identityfile'

Why not just let the agent try every key?

GitHub stops accepting keys after too many failed offers (Too many authentication failures), and deploy keys are locked to a single repo — offer a deploy key to the wrong repo and it fails. IdentitiesOnly yes + the alias means each connection offers exactly the right key, first time, every time.


8. Clean up 1Password

Once every key works through Bitwarden:

  1. Delete any exported key files from your Desktop / Downloads.
  2. Empty your Recycle Bin / Trash.
  3. Remove the plain private keys from ~/.ssh if you no longer keep them on disk (the agent holds them now) — but keep the .pub files, your config needs them.

Keep the SSH keys in 1Password for about a week in case you find a forgotten host. After that you can delete them there and, if you like, remove 1Password.


Final check

You should now have:

  • All SSH keys stored as SSH key items in Bitwarden
  • The Bitwarden SSH agent enabled and approving connections
  • SSH_AUTH_SOCK set on macOS / Linux (the named pipe on Windows, an npiperelay + socat bridge under WSL — and the same bridge again in ~/.vscode-server/server-env-setup if you use VSCode)
  • One Host * / IdentityAgent block at the end of ~/.ssh/config, so ssh finds the agent even where SSH_AUTH_SOCK is unset
  • A ~/.ssh/config block per host, with its .pub and IdentitiesOnly yes, so multiple keys on one host never collide and single-key hosts never run out of tries
  • Exactly one relay process — pgrep -x socat | wc -l says 1, however many terminals you have opened

Your private keys now live only in your vault, guarded by an approval prompt, and follow you to every machine you sign in to.