# [[How to use a YubiKey for GitHub with AI coding agents]]
![[How to use a YubiKey for GitHub with AI coding agents.svg]]
This is how I set up my Mac so that AI coding agents (like [[Claude Code]]) can commit, push and merge on [[GitHub]] for me, while I still use my [[YubiKey]] for GitHub over [[SSH]] and don't leave any unencrypted keys on disk. It's written so I can redo it on a new computer.
## The problem
Coding agents run commands in a shell that has no terminal (no TTY) attached. So whenever [[Git]] or SSH needs something from me, the agent can't ask:
- Typing the passphrase for a key file only works in a real terminal.
- Typing the YubiKey PIN only works in a real terminal.
- Nothing tells me when the YubiKey is waiting to be touched, so the agent's push just times out.
The fix is to have an **SSH agent** hold the unlocked keys, and a small **askpass script** that shows any remaining prompts as macOS dialogs.
## How it works in the end
| Action | What I have to do |
|---|---|
| Log in to the Mac | Run `sshload` once in a terminal and type each key's passphrase |
| Commit (signed) | Nothing |
| Push or pull | Touch the YubiKey when the "Touch your YubiKey" dialog appears |
| Merge a PR | Nothing locally: `gh pr merge --squash` or `--merge` |
Two keys are involved:
- **`~/.ssh/id_ecdsa_sk`**: the [[FIDO2]] key on the YubiKey, used to log in to GitHub. The file is only a pointer to the YubiKey, but it's still passphrase-protected so device-compliance tools don't flag it as an unencrypted key.
- **`~/.ssh/id_ed25519`**: a normal key file with a passphrase, used to **sign commits**. Signing with the YubiKey too would mean a touch for every commit (and every commit in a rebase), which is miserable.
## Prerequisites
- [[Homebrew]]: `brew install openssh libfido2 ykman gh`. Use Homebrew's OpenSSH for everything, because it has YubiKey (FIDO2) support built in.
- A FIDO2 SSH key on the YubiKey, added to GitHub as an **Authentication key**: [[How to generate an SSH key on the YubiKey for GitHub]]. Give the `id_ecdsa_sk` file a passphrase.
- Optionally a PIN on the YubiKey: [[Setting a PIN on YubiKey]].
- An ed25519 signing key with a passphrase (`ssh-keygen -t ed25519`), added to GitHub as a **Signing key** (GitHub → Settings → SSH and GPG keys → New SSH key → Key type: Signing Key). Adding it through the CLI needs the `admin:ssh_signing_key` scope: `gh auth refresh -s admin:ssh_signing_key`, then `gh ssh-key add ~/.ssh/id_ed25519.pub --type signing`.
## Step 1: The askpass script
OpenSSH runs the program named in `SSH_ASKPASS` whenever it needs to ask me something and there's no terminal. It tells the program what kind of prompt it is through `SSH_ASKPASS_PROMPT`:
- empty: a secret, like the YubiKey PIN. Show a hidden-input dialog and print only the answer.
- `confirm`: a yes/no question. Show OK/Cancel, print nothing, and exit 1 on Cancel.
- `none`: a notice, like "touch your key". Show something, and expect to be killed when it's done.
Save this as `~/.local/bin/ssh-askpass-mac` and run `chmod 700 ~/.local/bin/ssh-askpass-mac`:
```zsh
#!/bin/zsh
# SSH_ASKPASS helper: shows OpenSSH prompts (FIDO PIN, confirmations, touch
# notices) as macOS dialogs so agent-driven shells without a TTY can still ask.
# The prompt is passed via argv, never interpolated into AppleScript source.
prompt="${1:-SSH}"
case "${SSH_ASKPASS_PROMPT:-}" in
confirm)
/usr/bin/osascript - "$prompt" >/dev/null <<'AS' || exit 1
on run argv
display dialog (item 1 of argv) buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" with title "SSH"
end run
AS
exit 0
;;
none)
# Touch notice. macOS drops `display notification` from background processes
# like ssh-agent, so use a dialog. exec makes osascript the process ssh
# kills once the key is touched, which closes the dialog; it gives up after 30s.
exec /usr/bin/osascript - "Touch your YubiKey to continue.
$prompt" >/dev/null 2>&1 <<'AS'
on run argv
display dialog (item 1 of argv) buttons {"OK"} default button "OK" with title "SSH" with icon caution giving up after 30
end run
AS
;;
*)
/usr/bin/osascript - "$prompt" 2>/dev/null <<'AS' || exit 1
on run argv
set r to display dialog (item 1 of argv) default answer "" with hidden answer with title "SSH"
return text returned of r
end run
AS
;;
esac
```
## Step 2: Run the SSH agent at login
Create `~/Library/LaunchAgents/com.YOURNAME.ssh-agent.plist`. launchd doesn't expand `~` or `$HOME`, so use full paths (replace `YOURUSER`):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.YOURNAME.ssh-agent</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>rm -f /Users/YOURUSER/.ssh/agent.sock; exec /opt/homebrew/bin/ssh-agent -D -a /Users/YOURUSER/.ssh/agent.sock</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>SSH_ASKPASS</key>
<string>/Users/YOURUSER/.local/bin/ssh-askpass-mac</string>
<key>SSH_ASKPASS_REQUIRE</key>
<string>force</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
```
Notes:
- The `rm -f` is there because `ssh-agent -a` refuses to start if an old socket file is still lying around.
- `SSH_ASKPASS` and `SSH_ASKPASS_REQUIRE=force` have to be in the **agent's** environment, because the agent is what asks for the touch and the PIN. Setting them in `.zshrc` doesn't reach an agent that's already running.
- `~/.ssh` should be `drwx------` (owner only), since anything that can reach the socket can use the loaded keys.
Load it and check it's running:
```bash
plutil -lint ~/Library/LaunchAgents/com.YOURNAME.ssh-agent.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.YOURNAME.ssh-agent.plist
launchctl print gui/$(id -u)/com.YOURNAME.ssh-agent | grep state
SSH_AUTH_SOCK=~/.ssh/agent.sock /opt/homebrew/bin/ssh-add -l # "The agent has no identities." = working
```
## Step 3: Point everything at the agent
In `~/.zshrc`, make sure this is the **only** line that sets `SSH_AUTH_SOCK`. Delete any old ones, for example from yubikey-agent or gpg-agent:
```zsh
export SSH_AUTH_SOCK="/Users/YOURUSER/.ssh/agent.sock"
alias sshload='/opt/homebrew/bin/ssh-add ~/.ssh/id_ecdsa_sk ~/.ssh/id_ed25519'
```
In `~/.ssh/config`:
```
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ecdsa_sk
IdentitiesOnly yes
IdentityAgent /Users/YOURUSER/.ssh/agent.sock
```
Changing `SSH_AUTH_SOCK` globally also changes which agent every *other* SSH host uses. For hosts that should keep working the old way, add `IdentityAgent none` (no agent), or point them at the agent they need.
Tell Git to use SSH signing with Homebrew's tools:
```bash
git config --global core.sshCommand /opt/homebrew/bin/ssh
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global gpg.ssh.program /opt/homebrew/bin/ssh-keygen
```
`core.sshCommand` doesn't affect signing, which is why `gpg.ssh.program` is set separately.
## Step 4: GitHub CLI login, with no plaintext tokens
`gh` is what merges PRs (`gh pr merge`), so it needs a login. Git itself goes over SSH.
```bash
gh auth login --web --git-protocol ssh --skip-ssh-key
gh auth status # should say "(keyring)" and "Git operations protocol: ssh"
```
Make sure no plaintext GitHub tokens are lying around:
- Nothing like `credential.password` in `~/.gitconfig`.
- No `credential.helper store` (it saves tokens unencrypted in `~/.git-credentials`).
- No `GH_TOKEN` or `GITHUB_TOKEN` in shell profiles.
Any token found in those places should be revoked on GitHub: Settings → Developer settings → Personal access tokens, and Settings → Applications → Authorized OAuth Apps for app tokens. Tokens that are 40 hex characters with no prefix are old, pre-2021 format.
## Step 5: Every time I log in
1. Open a terminal and run `sshload`, then type both passphrases.
2. Start the coding agent **from a terminal opened after this setup**, so it inherits the new `SSH_AUTH_SOCK`.
The keys stay loaded until I log out, restart, or the agent restarts.
## Step 6: Check it works
```bash
/opt/homebrew/bin/ssh-add -l # should list ECDSA-SK and ED25519
/opt/homebrew/bin/ssh -T
[email protected]
# dialog appears → touch key → "Hi <username>! You've successfully authenticated..."
```
For signing, make a test commit on a throwaway branch and check the signature locally:
```bash
git switch -c signing-test && git commit --allow-empty -m "Test signed commit"
echo "$(git config user.email) $(cat ~/.ssh/id_ed25519.pub)" > /tmp/allowed_signers
git -c gpg.ssh.allowedSignersFile=/tmp/allowed_signers log -1 --format='%G? %GS' # "G" = good
git switch - && git branch -D signing-test
```
The commit email has to be a verified email on the GitHub account for GitHub to show "Verified".
## Getting the "touch your YubiKey" dialog to show
This took the most fiddling.
1. **A sound isn't enough.** I tried playing a sound; it's easy to miss if the volume is low.
2. **Notifications don't work.** `display notification` from `osascript` shows up as coming from **Script Editor**, which had notifications switched off (System Settings → Notifications → Script Editor). But even after I allowed them, and with no Focus mode on, nothing appeared when the *agent* triggered it. It only worked when I ran the script from my own terminal. macOS silently drops notifications from background processes like a launchd agent.
3. **Dialogs do work.** `display dialog` from the same background process *does* show up. So the `none` (touch) branch shows a dialog instead.
4. **Make the dialog close itself when I touch the key.** When the touch happens, ssh-agent kills the askpass process. Because the script uses `exec osascript`, the process being killed *is* the dialog, so it disappears. Without `exec`, the agent would only kill the zsh wrapper and the dialog would stay on screen. `giving up after 30` closes it anyway if I never touch.
5. **Clicking OK doesn't cancel anything.** It just hides the dialog; the key keeps blinking until touched or timed out.
How I worked out that the agent was calling the script but macOS was hiding the notification: I temporarily added this line near the top of the script:
```zsh
print -r -- "$(date "+%F %T") pid=$ ppid=$PPID mode=${SSH_ASKPASS_PROMPT:-secret} prompt=$prompt" >> "$HOME/Library/Logs/ssh-askpass.log"
```
If the log shows `mode=none` with the ssh-agent's PID as `ppid`, the agent is doing its job and the problem is on the display side. (Never log the answer from the secret branch.) To test a dialog in the same context as the agent without touching any keys:
```bash
launchctl submit -l askpass-test -- /usr/bin/env SSH_ASKPASS_PROMPT=none ~/.local/bin/ssh-askpass-mac "Background test"
launchctl remove askpass-test
```
Other script details that matter:
- The prompt is passed as an **argument** (`osascript - "$prompt"` with `item 1 of argv`), never pasted into the AppleScript text. Quotes or backslashes in a prompt could otherwise break the script or run code.
- The `confirm` branch must print **nothing**. OpenSSH treats an empty, successful answer as "yes", so any leftover output from the dialog would be taken as an answer.
## Troubleshooting
| Symptom | Cause |
|---|---|
| `Error connecting to agent: Connection refused` | `SSH_AUTH_SOCK` points at a dead socket, usually an old line in `.zshrc` |
| Passphrase asked on every commit, or on every commit during a rebase | Signing key isn't loaded in the agent. Run `sshload` first |
| `agent refused operation` / `Permission denied (publickey)` after ~30s | The YubiKey was never touched, usually because the dialog didn't show |
| `ssh-add --apple-use-keychain` fails | That flag exists only in Apple's `/usr/bin/ssh-add`, not Homebrew's |
| `--show-signature` says `gpg.ssh.allowedSignersFile needs to be configured` | Only local verification is missing. The commits are still signed |
| Push rejected: `Commits must have verified signatures` | Some commits were made unsigned. Re-sign them with the agent loaded: `git rebase --exec 'git commit --amend --no-edit -n -S' main` |
| Merged commits lost "Verified" | GitHub's **rebase** merge re-creates the commits without signatures. Use squash or merge |
## Things I decided against
- **Resident key** (`ssh-keygen -O resident`, loaded with `ssh-add -K`): no key file on disk, but it needs a new GitHub key and only saves typing one passphrase.
- **SSH connection sharing** (`ControlMaster`/`ControlPersist`): one touch could cover several pushes, but I couldn't confirm GitHub supports it, and it means pushes without a fresh touch.
- **1Password's SSH agent**: approves keys with Touch ID, but it doesn't support YubiKey FIDO2 keys, so it would only replace the signing half.
%%
# Excalidraw Data
## Text Elements
## Drawing
```compressed-json
N4KAkARALgngDgUwgLgAQQQDwMYEMA2AlgCYBOuA7hADTgQBuCpAzoQPYB2KqATLZMzYBXUtiRoIACyhQ4zZAHoFAc0JRJQgEYA6bGwC2CgF7N6hbEcK4OCtptbErHALRY8RMpWdx8Q1TdIEfARcZgRmBShcZQUebR44gAYaOiCEfQQOKGZuAG0AXX4IXDg4AGUoqHFUUDBIdXTqiCJlaRS6hkIECgAhXGwAa2VSYQ5iAGE2fDZSbggAYgAzZZX2
yGwRQKyASSr9CpGBhEnp2Yl5gEYEK6u1iA3SLahd9L7B4dGJqZm5qHIOZhwXBPO4PJ4vfQAMUI+HwFRgwTmgg8oM2mWeewObCOAHUSOpuHxwOs0TtMX9sQh4YiJMiSKjHuiIQAlYStDjhHJoC78EmMsnpADyQOwahg3AuiUSvPupIx6UhnCgkNw+hh4rQAFYZWCmXtFVkyoQjNUeNLibL+fL9AAVLBQACCLS4EmCiygDPB5OBjsebAokhCxG4HCE
sJ1cohAFExg6/QGg3NgSMqBGrRC4ymbfAmiMhGNPXqFeQMqyaahQ+GLcxsCNYQANbgAZgALHEeC3EgBOLsAdibFxbLc1A5lNbr+AAmtwAGxdxLaAAcTZnbc1vcXFx42otRjYBm4tQ69AIQmqF2JAF80170qz88QOcwueg8wWZcMSEaTYTzR1P8QFQIHA3A7v+pAkAAsmwxAIDGuCaMEwZoIsBBhB+EFfKch7EpAPRTMhr7KJouAABQ8BcvbULwlH
URRVGoAumoAJRrJAzIIMoYbAnMpDEWRPBNtKvBCXRomMdoLEQFeN7oliRzClA7AAiGYb4DKiwlggHFjEwhAcMoOF1JAmQIUh3B/GevLrEQIFoJZCDWRAHCqtUDlOcIUBEByFmkFZuEQPowJHKQAByrm+f5xmBcFTDwYhCCEQ5MkWnYABWCDYNkZQuXA0GwfF5koWhjkWv0SmMDa+74EZHSwIgSJpFlylsbKzBQAY2YNWglbqQF0yDIl3Cofg6EBZ
pBhlE1SmcMNJVOfgoSOs1lXVapsIpR0jjMGZxxKvakGZEIc2jaVHSaPpjgGQACoEixMJkYhzKZCXBjKhDMD0l36cohVDfZflnZAH2QSQcBsPpUA5SUcA/X9SWAzKmhsJgU3BM1nD5U0ehZLg+nSWA150IsMLhIel4gJeQA==
```
%%