Unified SSH and GPG signing with gpg-agent on Linux
I used to run ssh-agent and gpg-agent side by side, keeping separate SSH keys and GPG keys, with two different passphrases and two agents to babysit. This works, but it’s messy. gpg-agent can handle SSH authentication natively via a dedicated authentication subkey, so the separate ssh-agent is unnecessary.
This post documents the setup I settled on: a clean GPG key hierarchy with separated subkeys, gpg-agent handling both signing and SSH, and a shell configuration that makes pinentry appear in the right terminal regardless of whether I’m on a local desktop session or connected over SSH.
Key structure
The usual mistake is creating a single GPG key and using it for everything. The better approach is one primary key (for key management only) and separate subkeys for each operation:
| Key | Algorithm | Purpose |
|---|---|---|
| Primary | ed25519 | [C] Certify — sign other keys, manage subkeys |
| Subkey | ed25519 | [S] Sign — git commits, files |
| Subkey | cv25519 | [E] Encrypt — files, email |
| Subkey | ed25519 | [A] Authenticate — SSH |
The primary key only has the [C] capability. If a subkey is compromised, you revoke and replace just that subkey; the primary key and the rest of the hierarchy stay intact.
Note that [E] must use cv25519 (X25519), not ed25519. ed25519 is an EdDSA signing algorithm and cannot do Diffie-Hellman key exchange. The two are mathematically distinct operations.
Creating the key
gpg --expert --full-gen-key
At the key type menu, choose (11) ECC (set your own capabilities). The default capabilities for the primary key include [S]; toggle it off so only [C] remains, then press Q to confirm. Choose (1) Curve 25519 and set an expiry (I use 5 years).
Then add the three subkeys:
gpg --expert --edit-key your@email.com
gpg> addkey
# [S]: choose (10) ECC (sign only) → Curve 25519
gpg> addkey
# [E]: choose (12) ECC (encrypt only) → Curve 25519
gpg> addkey
# [A]: choose (11) ECC (set your own capabilities)
# toggle: S off, A on → Curve 25519
gpg> save
Verify the result:
gpg -K your@email.com
You should see [C] on sec and three ssb lines with [S], [E], [A].
gpg-agent configuration
~/.gnupg/gpg-agent.conf:
pinentry-program /usr/bin/pinentry-curses
default-cache-ttl 86400
max-cache-ttl 259200
default-cache-ttl-ssh 86400
max-cache-ttl-ssh 259200
enable-ssh-support
pinentry-curses is the key choice here. GUI pinentry programs (pinentry-qt, pinentry-gnome3) fail in SSH sessions. pinentry-curses renders directly in the terminal and works everywhere. The WAYLAND_DISPLAY and DISPLAY variables set on a desktop session leak into SSH sessions via the systemd user environment — if gpg-agent launches a GUI pinentry using those variables, it either fails silently or opens a window on the wrong display.
enable-ssh-support tells gpg-agent to listen on an additional SSH socket at $XDG_RUNTIME_DIR/gnupg/S.gpg-agent.ssh.
The default-cache-ttl-ssh and max-cache-ttl-ssh options are easy to miss: gpg-agent applies a separate, shorter TTL to keys accessed via the SSH socket. Without setting them explicitly, SSH authentication will prompt for a passphrase again after 30 minutes (the built-in default) even though other GPG operations remain cached for hours. Set them to match the regular TTL.
After changing this file, restart the agent:
gpgconf --kill gpg-agent
Exposing the authentication subkey over SSH
gpg-agent does not automatically expose all [A] subkeys via SSH. You have to explicitly list the keygrips you want exposed in ~/.gnupg/sshcontrol:
# Find the [A] subkey keygrip
gpg --with-keygrip -K your@email.com
# Add it to sshcontrol
echo "<keygrip>" >> ~/.gnupg/sshcontrol
Get the SSH public key to deploy to servers:
gpg --export-ssh-key your@email.com
Shell configuration
Three things need to be in place in the shell.
Point SSH at gpg-agent’s SSH socket. The default SSH_AUTH_SOCK on most systems points at ssh-agent. Override it:
export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)
If you use systemd --user, you can set this permanently in ~/.config/environment.d/gpg-agent.conf instead so it applies to all processes, not just shells:
SSH_AUTH_SOCK=${XDG_RUNTIME_DIR}/gnupg/S.gpg-agent.ssh
Prevent SSH from trying a GUI askpass. When DISPLAY is set (which it is on any desktop session, and it leaks into SSH sessions as noted above), SSH falls back to an askpass helper program when agent authentication fails. This program usually doesn’t exist, producing a confusing error. Disable it:
export SSH_ASKPASS_REQUIRE=never
This can go in the same environment.d file.
Set GPG_TTY and keep pinentry in the right terminal. gpg-agent is a persistent daemon. It keeps an internal record of which terminal to use for pinentry calls, updated by the UPDATESTARTUPTTY command. Without help, it uses whatever terminal last interacted with it — which may not be the one you’re working in.
The fix is a preexec hook (zsh) that updates the registered TTY immediately before any command that might trigger GPG:
# Must come before p10k instant prompt if you use Powerlevel10k —
# p10k redirects file descriptors during init, making tty(1) return
# "not a tty" for the rest of the shell's life.
export GPG_TTY=$(tty)
# ... p10k instant prompt block here if applicable ...
autoload -Uz add-zsh-hook
_gpg_update_tty() {
case ${1%% *} in
ssh|git|gpg) gpg-connect-agent updatestartuptty /bye >/dev/null 2>&1 ;;
esac
}
add-zsh-hook preexec _gpg_update_tty
The case filter limits the socket call to commands that actually use GPG, keeping the overhead negligible. Without this hook, the pinentry prompt appears wherever gpg-agent last heard from — reliably the wrong place when you have multiple terminals open.
The Powerlevel10k note matters: p10k’s instant prompt feature replaces the shell’s stdin/stdout/stderr with pipes during initialization. Any $(tty) call after that block returns not a tty, and gpg-agent stores the wrong value. GPG_TTY=$(tty) must run before the p10k if [[ -r ... ]] block.
git signing
Tell git to use the [S] subkey. Use the full fingerprint with a trailing ! to pin the exact subkey rather than letting GPG walk up to the primary:
# Get the [S] subkey fingerprint
gpg --with-subkey-fingerprint -K your@email.com
git config --global user.signingkey "<S-subkey-fingerprint>!"
git config --global commit.gpgsign true
Verifying the setup
# Agent exposes the correct SSH key
ssh-add -L
# SSH authentication works
ssh your-server
# git signing works
git commit --allow-empty -m "test"
# GPG signing works (triggers pinentry in current terminal)
echo test | gpg --clearsign
The first time each subkey is used after an agent restart, gpg-agent will prompt for the passphrase via pinentry-curses in the current terminal. After that it’s cached for default-cache-ttl seconds (24 hours in the config above), with a hard expiry of 72 hours regardless of use.
To check which keys are currently cached:
gpg-connect-agent 'keyinfo --list' /bye
The fifth field on each line is the cache state: 1 means cached, - means not.