DEV Community

Cover image for Managing SSH keys for work

Managing SSH keys for work

We recently started using Azure DevOps internally at UiPath for all non-public repos, and I was very excited when I saw it using Terraform in the PR workflows for setting up teams, repos, and managing everything. But more on that in a future post, first I had to sort out managing 3 different SSH keys on my work laptop without needing to specify which key to use for every git command.

At UiPath, we use GitHub for any public or private product repos, and then Azure DevOps (ADO for short) for any other internal repos we want to share across teams. Add in my personal GitHub contributions, and I now have 3 different SSH keys to manage for commits and signing.

So what is the problem?

Why a separate personal key at all, if one GitHub account would let me sign everything with the work key? Because I don't want to deal with the chaos when I need to revoke a key, and using only 1 means it would affect both my work and personal commits when I revoke it. I prefer my personal commits be tied to a key and an email that I own and control, and the work ones to a key that is linked with work. It keeps the line between myself and alter ego "UiPath-Cobus" very clean. We've all seen the "Opinions are my own and don't reflect those of my employer" line on profiles, I'm curious how much that shielded you when you pushed some code with the wrong details.

The end result is a setup where I can clone repos and push commits without having to think about which version of me is shown in the commit history.

The Plan

Initially it didn't create too much friction to keep track of which key to use as I almost never used my personal one. Then we started using ADO to package and share internal AI scripts, skills, and tools, so I decided it was time to sort this out properly after I last had to deal with it in 2012. Luckily, we also use 1Password at work, so it matched my home setup where I use the SSH agent to manage my SSH keys, I just had to store them in the correct vault.

I knew roughly what I wanted the git behaviour to be, but I needed to write it out clearly enough to use in a prompt, so I did a rough mapping of some examples:

remote URL                               ssh key       email / signing key
───────────────────────────────────────  ────────────  ───────────────────
git@github.com:UiPath/*                  work          work (explicit)
git@github.com:uipath-labs/*             work          work (explicit)
git@github.com:cobusbernard/*            work          personal
git@github.com:anyone-else/*             work          personal
git@ssh.dev.azure.com:v3/uipath/*        azure (RSA)   work (default)
https://dev.azure.com/uipath/*           n/a (GCM)     work (default)
no remote yet                            n/a           work (default)
Enter fullscreen mode Exit fullscreen mode

To set up this mapping required editing both ~/.ssh/config and ~/.gitconfig (with additional ~/.gitconfig-<flavour>ones I'll show further down) with rules on which SSH key to pick per repo. I initially had a repo URL rewrite rule, but discarded that approach when I relealised I would need to update every single repo I already had cloned locally.

The decision tree then became the following for any work repo on GitHub:

git push    remote.origin.url = git@github.com:UiPath/some-repo.git
β”‚
β”œβ”€β–Ί ~/.gitconfig
β”‚   └─ includeIf "hasconfig:remote.*.url:git@github.com:<arcane regex to match org name for UiPath>"
β”‚      ──► pulls in ~/.gitconfig-github-uipath
β”‚          (work email + work signing key)
β”‚
β”œβ”€β–Ί ~/.ssh/config
β”‚   └─ Host github.com
β”‚         IdentityFile ~/.ssh/id_ed25519.pub   (the work key)
β”‚         IdentitiesOnly yes
β”‚
└─► 1Password SSH agent
    ──► "the key matching this .pub, please" ──► Touch ID ──► πŸŽ‰
Enter fullscreen mode Exit fullscreen mode

You'll notice that there isn't a split in the ~/.ssh/config between my work and personal SSH keys. The decision on which key to use happens at commit (and signing) time based on the configs for the key and email inside each repo, which is managed with the different .gitconfig* files. In the example above, the repo URL matches the catchall or default that I set to my personal GitHub key.

Step 1: Generate the keys

I keep my SSH keys in 1Password rather than as files on disk, storing the work ones in my UiPath personal vault and any personal ones in my personal vault. The private key never leaves the vault, the agent prompts with Touch ID when something wants to use a key, and when I get the next new laptop the keys are just... there. Generating is a case of New Item β†’ SSH Key β†’ Generate in the app.

If you aren't using 1Password, you can just remove the config lines that reference the agent in the code blocks below, and change the keys in the ~/.ssh/config to use the private keys by removing the .pub from the end after generating them with:

  1. ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -C "my-work-email@example.com"
  2. ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_personal -C "my-personal-email@example.com"
  3. ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -C "my-work-email@example.com"

These are my keys on my work laptop:

Key File Type Notes
GitHub - work id_ed25519 Ed25519 Filed under "default to use currently" in my brain.
GitHub - personal id_ed25519_personal Ed25519 Same as above.
Azure DevOps id_rsa RSA 4096 Azure DevOps only supports RSA. Yes, in 2026.

A note on that RSA key, because the Microsoft docs send you down a rabbit hole: older versions told you to run ssh-keygen -t rsa-sha2-256. That works, but rsa-sha2-256 isn't a key type, it's a signature algorithm. The key that comes out is a plain ssh-rsa key, identical to what ssh-keygen -t rsa -b 4096 produces. What Azure DevOps actually deprecated in 2022 was the old SHA-1 signature (ssh-rsa as an algorithm), and any OpenSSH from the last decade negotiates rsa-sha2-512 automatically. So: a normal RSA key, nothing special. If you find old forum threads saying "1Password's agent doesn't work with Azure DevOps", they're from before Microsoft added rsa-sha2 support. It works fine now.

Next, download the public half of each key from 1Password (the little dropdown on the "public key" field has a Download option) and save them as:

~/.ssh/id_ed25519.pub            # GitHub, work
~/.ssh/id_ed25519_personal.pub   # GitHub, personal
~/.ssh/id_rsa.pub                # Azure DevOps
Enter fullscreen mode Exit fullscreen mode

These public keys are how we tell the agent which of its keys to use for a given host. More on that in a second. You now need to upload the public parts of each keypair, and then also upload them separately for signing on GitHub only (Azure DevOps doesn't do commit signature verification at all as far as I can tell, so there's no signing entry to add.):

  • GitHub: Settings β†’ SSH and GPG keys. Both the work and personal keys are added under Authentication Key - I'm assuming you are following my pattern of having a distinct personal key when using your work computer. Both Ed25519 keys go in as Signing Keys, because GitHub only shows the Verified badge when the signature matches a signing key on the account that owns the commit's email. Both emails need to be verified on the account too.
  • Azure DevOps: User settings β†’ SSH public keys β†’ New Key, and paste in the RSA one.

Step 2: Configure ssh to use different keys

Next we need to configure ssh to know which key to use based on the URL of the repo we are dealing with with the following in ~/.ssh/config:

Host github.com
    User git
    IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
    IdentityFile ~/.ssh/id_ed25519.pub
    IdentitiesOnly yes

Host ssh.dev.azure.com
    User git
    IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
    IdentityFile ~/.ssh/id_rsa.pub
    IdentitiesOnly yes

# Everything else still goes through the 1Password agent
Host *
    IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"
Enter fullscreen mode Exit fullscreen mode

The important pair of lines in each block is IdentityFile <something>.pub + IdentitiesOnly yes. Without them, the agent offers every key it has, one after the other, until the server accepts one. With three keys in the agent and the RSA one first, Azure DevOps is happy but GitHub gets offered all the keys available in ~/.ssh that will lead to Too many authentication failures because the server gave up after six attempts. If you've never looked at what SSH does when it connects to a server, add in the very very very verbose parameter with ssh -vvvv <rest> to see the details. Specifying IdentitiesOnly yes says "only offer what I've listed", and pointing the IdentityFile at a public key is the documented way to tell an agent which private key you mean without having the private key on disk.

There's no HostkeyAlgorithms +ssh-rsa or PubkeyAcceptedAlgorithms +ssh-rsa in the Azure DevOps block. If you have those lines from an older setup, delete them; they re-enable the SHA-1 algorithm Microsoft is trying to get rid of.

Linux: the agent socket is ~/.1password/agent.sock. On macOS you can create a symlink with ln -s ~/Library/Group\ Containers/2BUA8C4S2C.com.1password/t/agent.sock ~/.1password/agent.sock and use then use that instead of the full path. I prefer not to as I always forget to add it again when I move to a new laptop.

Windows: the 1Password agent takes over the \\.\pipe\openssh-ssh-agent named pipe, so there is no IdentityAgent line, just drop it from every block. You do need to stop and disable the OpenSSH Authentication Agent Windows service (1Password's setup guide walks through it), and tell Git for Windows to use the Windows OpenSSH client rather than its bundled one, because the bundled one can't see the pipe: git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe". The config file lives at C:\Users\<you>\.ssh\config with the same syntax.

Step 3: Rules for git on which keys to use when

Now the part that took me the longest to get right. The global config holds the default (work) identity, the signing setup, the credential helper for Azure DevOps over HTTPS, and the conditional includes. The rules specified with the includIf lines point to specific ~/.gitconfig-<name> files to make it easier to manage if I need to add another set of rules in future. The main ~/.gitconfig file I have is:

[user]
    name = Cobus Bernard
    email = cobus@work.example       # default identity: work
    signingkey = ssh-ed25519 AAAA...work   # the work *public* key, in full

[gpg]
    format = ssh
[gpg "ssh"]
    program = /Applications/1Password.app/Contents/MacOS/op-ssh-sign
    allowedSignersFile = ~/.ssh/allowed_signers
[commit]
    gpgsign = true

# Azure DevOps over HTTPS uses Git Credential Manager instead of SSH
[credential]
    helper =
    helper = /usr/local/share/gcm-core/git-credential-manager
[credential "https://dev.azure.com"]
    useHttpPath = true
[credential "azrepos:org/uipath"]
    username = cobus@work.example

# Azure DevOps repos, SSH and HTTPS forms
[includeIf "hasconfig:remote.*.url:git@ssh.dev.azure.com:v3/uipath/**"]
    path = ~/.gitconfig-azure
[includeIf "hasconfig:remote.*.url:https://dev.azure.com/uipath/**"]
    path = ~/.gitconfig-azure
[includeIf "hasconfig:remote.*.url:https://*@dev.azure.com/uipath/**"]
    path = ~/.gitconfig-azure

# GitHub: personal identity by default...
[includeIf "hasconfig:remote.*.url:git@github.com:*/**"]
    path = ~/.gitconfig-github-personal
[includeIf "hasconfig:remote.*.url:https://github.com/*/**"]
    path = ~/.gitconfig-github-personal

# ...unless the org name starts with "uipath" (any casing).
# Must come AFTER the personal includes.
[includeIf "hasconfig:remote.*.url:git@github.com:[Uu][Ii][Pp][Aa][Tt][Hh]*/**"]
    path = ~/.gitconfig-github-uipath
[includeIf "hasconfig:remote.*.url:https://github.com/[Uu][Ii][Pp][Aa][Tt][Hh]*/**"]
    path = ~/.gitconfig-github-uipath
Enter fullscreen mode Exit fullscreen mode

I then have 3 named .gitconfig files that contain the values to overwrite from the original one.

~/.gitconfig-github-personal:

[user]
    email = cobus@personal.example
    signingkey = ssh-ed25519 AAAA...personal
Enter fullscreen mode Exit fullscreen mode

~/.gitconfig-github-uipath:

[user]
    email = cobus@work.example
    signingkey = ssh-ed25519 AAAA...work
Enter fullscreen mode Exit fullscreen mode

~/.gitconfig-azure:

[user]
    signingkey = ssh-rsa AAAA...azure
Enter fullscreen mode Exit fullscreen mode

(Yes, signing Azure DevOps commits with the RSA key is pointless since Azure DevOps ignores signatures. It is there so that git commit behaviour is the same for all repos and will open the 1Password dialog to allow access to the key.)

Notes to future self

I ran into these issues as I was setting this up and that's what made me decide to write it down so I can reference my TILs next time I make the same mistakes:

includeIf "hasconfig:remote.*.url:..." (Git 2.36+) conditionally includes a config file based on the remote URLs in the current repo. The better-known includeIf "gitdir:..." version does the same based on where the repo lives on disk, which means you have to be disciplined about ~/work/ vs ~/personal/ folders. In a next life I may be this organized, but today is not that day, so I went with the URL matching route to protect me from myself.

Last include wins. If two includeIf blocks match the same repo, both files get included in order and the later one's user.email overrides. That's why the uipath block is last so that when a repo is under a UiPath org, the work config overrides the personal one.

It works during clone, not just afterwards. git clone writes remote.origin.url into the new repo's config before it fetches, and the include fires from then on. So git clone git@github.com:UiPath/some-repo.git gets the work identity from the very first commit. No clone-then-fix-the-email dance.

While I didn't run into any issues with the repos I already had cloned locally, I did use git config --show-origin user.signingkey in each of them as a sanity check before I made any new commits.

The HTTPS variants are not optional. Azure DevOps hands out https://dev.azure.com/uipath/... URLs by default, and some tooling adds your username in front of the host, so there are three patterns for one place. GitHub's HTTPS clone URL gets the same treatment.

The Plural of "regex" is "regrets"

To be clear, I haven't written regex by hand in a long time, originally you had to combine StackOverflow posts with online regex testers to fudge one that worked together. My Claude's first version of the uipath rule was:

git@github.com:*[Uu][Ii][Pp][Aa][Tt][Hh]*/**
Enter fullscreen mode Exit fullscreen mode

With a leading *, the patterns should stop at a / the way it does in a shell. So "uipath anywhere in the org name" and a repo called cobusbernard/uipath-scripts would correctly stay personal.

Nope. So a few more tokens were burned to confirm why it wasn't working with a personal repo with uipath in the name (I use this repo to share personal utility scripts to my work laptop):

$ cd /tmp && git init test && cd test
$ git remote add origin git@github.com:cobusbernard/uipath-scripts.git
$ git config user.email
cobus@work.example
Enter fullscreen mode Exit fullscreen mode

The * happily matched cobusbernard/ and my personal repo used my work email and key. This is exactly what I was trying to avoid - as a rule I don't use my work laptop for personal work. This would have been a one-way pull from my personal repo to my work laptop, but I really don't want to accidentally commit to my personal repo with my work laptop.

The docs do say the pattern uses "standard globbing wildcards" plus **, they just don't spell out that the standard * here crosses /. Dropping the leading * so the pattern is anchored to the start of the org name fixes it:

git@github.com:[Uu][Ii][Pp][Aa][Tt][Hh]*/**
Enter fullscreen mode Exit fullscreen mode

It matches UiPath/ and uipath-labs/ at the start as the org name, and leaves cobusbernard/uipath-demo alone. The [Uu][Ii]... character classes are there because the match is case-sensitive and GitHub happily hands out both UiPath/ and uipath-labs/ style URLs.

Fun fact: URLs are case sensitive for anything that isn't the domain or protocol (https, http, ftp) - see RFC3986 and w3.org, so technically https://github.com/uipath and https://github.com/UiPath are 2 different URLs, but I haven't run into that with GitHub ever.

Step 4: Setting up commit signing

Almost done now, the rest of this post is more informational notes to my future self. Most of the signing config is already in ~/.gitconfig above, the important lines are:

  • gpg.format = ssh tells git to sign with an SSH key instead of GPG (Git 2.34+).
  • user.signingkey is the public key, in full, not a path. Each include file sets its own, so personal repos sign with the personal key, UiPath repos sign with the work key, and Azure DevOps repos sign with the RSA key.
  • gpg.ssh.program = op-ssh-sign hands the actual signing to 1Password, which means a Touch ID prompt on git commit. The private key stays in the vault.
  • commit.gpgsign = true so I don't have to remember -S.

To verify signatures locally (git log --show-signature), git also needs an allowed-signers file mapping emails to public keys, which is what gpg.ssh.allowedSignersFile points at:

# ~/.ssh/allowed_signers
cobus@work.example ssh-ed25519 AAAA...work
cobus@personal.example ssh-ed25519 AAAA...personal
Enter fullscreen mode Exit fullscreen mode

Without it, git log --show-signature complains that gpg.ssh.allowedSignersFile needs to be configured. It doesn't block anything, it's just another extra warning each time I did a commit.

GitHub shows a commit as Verified when the signature matches a signing key registered on the account that owns the commit's email address. That's the whole reason email and signing key live together in the include files: if the email says personal but the signature is from the work key, GitHub shrugs and shows Unverified.

Linux/Windows signing program: /opt/1Password/op-ssh-sign on Linux, and C:/Users/<you>/AppData/Local/1Password/app/8/op-ssh-sign.exe on Windows (forward slashes are fine in .gitconfig). Or let 1Password write it for you: open the SSH key item, β‹― β†’ Configure Commit Signing β†’ Edit Automatically. Just move the signingkey line it writes into the right include file afterwards, because it writes to the global [user] section and will stomp on the default when you run it for the second key.

Step 5: Does It Actually Work?

Usually you would test access with ssh -T git@github.com and it will return Hi cobusbernard! . This doesn't tell me if the setup is correct, as any SSH key I have set up on my GitHub account will be successful. On ADO, it looks like it failed, but this is the success message, if the key was invalid, you would get a "permissions denied" error:

$ ssh -T git@ssh.dev.azure.com
remote: Shell access is not supported.
Enter fullscreen mode Exit fullscreen mode

To properly test, you need to run the following inside the each repo you have cloned locally:

$ git config --show-origin user.email
file:/Users/cobus/.gitconfig-github-personal    cobus@personal.example

$ git config --show-origin user.signingkey
file:/Users/cobus/.gitconfig-github-personal    ssh-ed25519 AAAA...personal
Enter fullscreen mode Exit fullscreen mode

The --show-origin flag will print out which file the config value displayed comes from. If you are still having issues, it is time to dive into the detailed ssh logs to see which key was sent. Remember the part earlier where I used IdentitiesOnly yes to ensure only 1 specific key would be sent? I used the following to troubleshoot:

GIT_SSH_COMMAND="ssh -v" git fetch 2>&1 | grep -E 'Offering|Server accepts|Authenticated'
Enter fullscreen mode Exit fullscreen mode

You should see only 1 line for Offering public key, and it should match the .pub you specified in your configs, otherwise there is a mistake somewhere.

Honourable Mentions

These are some more notes for future-Cobus that caused present-Cobus some confusion:

  • includeIf hasconfig only fires inside a repo. git ls-remote git@github.com:UiPath/x.git run from your home directory has no remotes to match, so it uses the default. In practice this never matters, but it confused me for ten minutes when I was testing my setup.
  • The ssh:// URL form is a different prefix. GitHub's UI gives you git@github.com:org/repo.git, which is what all the patterns above use. If you also use ssh://git@github.com/org/repo.git style URLs, you need to add a matching includeIf with ssh://git@github.com/**. I don't currently have this issue, but adding so I can find this post when I do need it.
  • Check your git version. hasconfig needs 2.36 (2022), SSH signing needs 2.34. Apple's bundled git is fine on anything recent; brew install git if not.
  • The empty helper = line is on purpose. Git appends credential helpers rather than replacing them, so an empty value first clears whatever the system gitconfig set, and then 2nd line sets the only one I wanted to use.

Wrapping it up

While I had to deal with this a long time ago and knew the rough outlines of how to handle it, it did seem overly complex when I started redoing it. Any time you do something for the first time, this is the case until you understand it, then it almost feels like it is simple. In the end I had to only edit ~/.ssh/config and ~/.gitconfig, and then add the 3 extra ~/.gitconfig-<name> files to make it nice and clean.

This is the setup I ended with after going back and forth with Claude, if you've got a cleaner way to do the case-insensitive org match, or you've found a way to make Azure DevOps accept an Ed25519 key, I'd love to hear it. Let me know in the comments below!

Top comments (0)