Shortly after upgrading my workstation to Ubuntu 26.04 LTS, I noticed that my KeePassXC SSH agent integration had quietly stopped behaving the way it used to. The keys I store in KeePassXC, which I use in conjunction with git and SSH, were no longer being loaded and unloaded as I unlocked and locked its databases, so this was something I wanted to get to the bottom of.
It took a fair bit of digging (and a diagnosis session with Claude) to untangle, but I eventually discovered the root cause was entirely my own doing: years ago I’d enabled SSH support in gpg-agent - because I was using GPG based Git signing keys at the time - and then completely forgotten about it. That setting sat dormant for years, and then eventually changed the way my KeePassXC worked, around my upgrade to 26.04. (I’m still unsure exactly which update changed things to be honest.)
So if you’ve ever enabled gpg-agent’s SSH support, a modern GNOME desktop may quietly stop KeePassXC behaving the way you expect (I think I hit it on Ubuntu 26.04). If you’ve never touched gpg-agent, you’ll likely sail through and never see this. I’ll walk through how it works, how to fix it (it’s a one-liner), and how to roll back.
Are you affected?
The real precondition here isn’t the Ubuntu version - it’s whether you’ve ever enabled gpg-agent’s SSH support. This post is for you if you use KeePassXC’s SSH agent integration and find that:
- KeePassXC’s SSH agent integration no longer adds/removes keys as you unlock/lock your database, or
- your keys remain available to
ssh/giteven when KeePassXC is locked, or ssh-add -lshows keys that you can’t get rid of, even after locking KeePassXC.
To check this you can run:
grep enable-ssh-support ~/.gnupg/gpg-agent.conf
If that line is present and not commented out, then at some point you opted gpg-agent into serving SSH keys - and that single setting is the thing that makes all the difference.
How KeePassXC’s SSH agent integration actually works on Linux
When you unlock a KeePassXC database, for every entry that has an SSH key configured with “add to agent”, KeePassXC decrypts the private key and hands it to some other agent by speaking the ssh-agent protocol over the socket named by the SSH_AUTH_SOCK environment variable. When you lock the database, it asks that same agent to remove the keys again.
So the integration is only ever as good as:
- which agent
SSH_AUTH_SOCKpoints at, and - whether that agent behaves ephemerally - holding keys in memory and honouring removal requests.
The first of those - which agent - is what changed for me. And because the agent that took over (gpg-agent) isn’t ephemeral, the second assumption collapsed right along with it.
Why it happens
The catch is that on a modern GNOME desktop, more than one agent wants to own SSH_AUTH_SOCK. On my machine three were running at once, each on its own socket:
| Agent | Socket | Persists keys? |
|---|---|---|
gpg-agent (with enable-ssh-support) |
…/gnupg/S.gpg-agent.ssh |
Yes - to disk |
gcr-ssh-agent (GNOME default) |
…/gcr/ssh |
No (in-memory) |
plain ssh-agent |
…/openssh_agent |
No (in-memory) |
Each socket unit, as it starts, runs an ExecStartPost that points SSH_AUTH_SOCK at its own socket. They all write the same variable, so whichever starts last wins - and the start order is fixed (via Before=/After= directives) so that gpg-agent’s socket comes last. That’s deliberate: ssh-agent and gcr-ssh-agent are on by default, but gpg-agent’s SSH support is opt-in, so if you enabled it the desktop assumes you want it to win.
Here’s the subtle, crucial detail, though - the part that turns the fix into a one-liner. gpg-agent-ssh.socket does not claim SSH_AUTH_SOCK unconditionally. Its ExecStartPost is guarded: it only exports the variable if enable-ssh-support is actually switched on:
# from gpg-agent-ssh.socket (simplified):
[ -z "$(gpgconf --list-options gpg-agent | awk -F: '/^enable-ssh-support:/{print $10}')" ] \
|| systemctl --user set-environment SSH_AUTH_SOCK=%t/gnupg/S.gpg-agent.ssh
In plain English: “only grab the SSH socket if the user opted in.” So gpg-agent-ssh.socket can be enabled and running and still leave SSH_AUTH_SOCK completely alone - as long as enable-ssh-support is off.
Put the two halves together and the breakage makes sense. On a default session - with enable-ssh-support off - gpg-agent’s socket declines the race, and gcr-ssh-agent (which it’s ordered to run after) ends up owning SSH_AUTH_SOCK: an ephemeral agent, exactly what you want. But because I had enabled SSH support years before, gpg-agent’s guarded ExecStartPost fired, it won the ordering by running last, and it silently took over the socket. My dormant setting suddenly had teeth.
The nastier trap: gpg-agent persists your keys to disk
In my untangling of this I discovered, unlike a plain ssh-agent or gcr-ssh-agent, gpg-agent does not hold ssh keys in memory - it writes them to disk. When KeePassXC (or you, via ssh-add) hands a key to gpg-agent’s ssh socket, gpg-agent:
- writes the private key into
~/.gnupg/private-keys-v1.d/<keygrip>.key, and - registers the keygrip in
~/.gnupg/sshcontrol(the registry of ssh keys it will serve).
That on-disk .key file copy lives on your filesystem until you delete it, and the keygrip stays registered regardless of whether KeePassXC is unlocked. (The optional TTL field in sshcontrol only controls passphrase caching, and is moot anyway for a passwordless key.) ssh-add -l lists registered keygrips, so the key keeps showing up there too.
From that point on, the key is served from gpg-agent’s on-disk copy whether or not KeePassXC is unlocked. That’s why my keys “worked” but never disappeared on lock - KeePassXC wasn’t really in the loop any more, and a copy of my private key was now sitting on disk, which rather defeats the point of keeping it in a password manager.
Whether that on-disk copy is encrypted depends on the key. If it carries a passphrase, gpg-agent stores it protected (protected-private-key, AES-encrypted via a passphrase-derived key). A passwordless key, though, is written out in plaintext - private exponent and all (the resulting .key file holds a raw (private-key (rsa …)) with no protected wrapper). Either way the copy outlives your database lock - that’s the core problem - but a cleartext private key on disk is about as far from “keys only live in my password manager” as you can get.
Diagnosis: work out what’s happening on your machine
Before changing anything, gather the facts. None of these commands modify anything:
# Which socket is everything using?
echo "$SSH_AUTH_SOCK"
# Which keys are currently loaded?
ssh-add -l
# Which agents are actually running?
ps -u "$USER" -o pid,comm | grep -E 'ssh-agent|gcr|gpg-agent|keepass'
# Has gpg-agent persisted any ssh keys to disk?
cat ~/.gnupg/sshcontrol # keygrips listed here are ssh keys gpg-agent serves
ls -la ~/.gnupg/private-keys-v1.d/ # the matching <keygrip>.key files
# Is gpg-agent's ssh support enabled?
grep enable-ssh-support ~/.gnupg/gpg-agent.conf
If sshcontrol lists keygrips and enable-ssh-support is present in gpg-agent.conf, you’re likely in exactly the situation I was.
The fix
Now that we know enable-ssh-support is the one thing pulling gpg-agent into the picture, the fix is refreshingly small: turn that setting off. With it off, gpg-agent’s socket bows out of the SSH_AUTH_SOCK race on its own (that guarded ExecStartPost again), gcr-ssh-agent wins by default, and KeePassXC talks to an ephemeral agent exactly as it should.
If gpg-agent had previously been serving your keys, there’s also a one-time cleanup: the private keys it copied to disk. That’s the only other thing to do.
Step 1: Disable gpg-agent’s SSH support
Edit ~/.gnupg/gpg-agent.conf and comment out (or delete) the line:
# enable-ssh-support
Then restart gpg-agent so it picks up the change:
$ gpgconf --kill gpg-agent
That’s the whole behavioural change. You can leave gpg-agent-ssh.socket enabled and running - with enable-ssh-support off, it no longer touches SSH_AUTH_SOCK.
Step 2: Purge the keys gpg-agent persisted to disk
This step only applies if your diagnosis showed keygrips in ~/.gnupg/sshcontrol. If sshcontrol was empty, skip to Step 3.
⚠️ Important safety warning:
~/.gnupg/private-keys-v1.d/also holds your GPG/PGP private keys if you use GPG for email or commit signing. Only ever delete the keygrips that are listed insshcontrol- those are the SSH ones. Never blanket-delete this directory.
First, back the files up, just in case (your originals live safely in KeePassXC, but a belt-and-braces backup costs nothing):
$ cd ~/.gnupg
$ tar czf ~/ssh-keys-purged-backup.tar.gz sshcontrol private-keys-v1.d/
$ chmod 600 ~/ssh-keys-purged-backup.tar.gz
Now, for each keygrip listed in sshcontrol, delete its private key file:
$ rm ~/.gnupg/private-keys-v1.d/<KEYGRIP>.key
Then empty out the ssh entries from sshcontrol (keep the comment header at the top; just remove the keygrip lines), and restart gpg-agent so the changes take effect:
$ gpgconf --kill gpg-agent
Clearing sshcontrol stops gpg-agent serving the keys; deleting the .key files removes the on-disk copies. You want both.
Step 3: Log out, log back in, and verify
The change takes full effect on a fresh session, so log out and back in (or simply reboot).
Once you’re back, confirm the wiring:
# Should now point at gcr, not gnupg:
$ echo "$SSH_AUTH_SOCK"
/run/user/1000/gcr/ssh
# gpg-agent's ssh socket may still be active - that's fine - it just
# shouldn't own SSH_AUTH_SOCK any more:
$ systemctl --user show-environment | grep SSH_AUTH_SOCK
SSH_AUTH_SOCK=/run/user/1000/gcr/ssh
# With KeePassXC LOCKED, the agent should be empty:
$ ssh-add -l
The agent has no identities.
Now for the real test:
# Unlock your KeePassXC database, then:
$ ssh-add -l # → your keys should appear
# Lock the database, then:
$ ssh-add -l
The agent has no identities.
If your keys appear on unlock and vanish on lock, you’re fixed.
If your keys don’t appear when you unlock, check Tools → Settings → SSH Agent in KeePassXC (the integration must be enabled), and each key entry’s Advanced → SSH Agent tab, where “Add key to agent when database is unlocked” needs to be ticked.
Once you’re happy everything works, delete the backup from Step 2 - it still contains a copy of your private keys on disk, which is exactly what you were trying to avoid:
$ rm ~/ssh-keys-purged-backup.tar.gz
Rollback plan
There’s very little to undo, and your keys are never at risk - they live in KeePassXC throughout, so the most you’re ever changing is how they’re presented to an agent.
- Restore gpg-agent’s SSH support - uncomment
enable-ssh-supportin~/.gnupg/gpg-agent.confand rungpgconf --kill gpg-agent. -
Restore the purged keys (only if you took the Step 2 backup and actually need them back):
$ cd ~/.gnupg && tar xzf ~/ssh-keys-purged-backup.tar.gz - Log out and back in (or reboot) to return to the previous behaviour.
That really is all - because the fix was a single setting, the rollback is too.
A wider point: gpg-agent and KeePassXC aren’t really compatible
It would be easy to read all of the above as “Edd left a setting on and tripped over it” - and that’s true. But there’s a more general lesson, because it applies even if you enabled enable-ssh-support completely deliberately and want to keep it.
KeePassXC’s own documentation has a topic dedicated to using GPG’s SSH agent with KeePassXC. From the SSH Agent docs:
GNU Privacy Guard (gpg) with its SSH agent implementation is not compatible with KeePassXC as it does not support removing keys that have been added to it making it impossible to use any external tool to manage key lifetime.
Read that with your security hat on. The whole value of KeePassXC’s SSH integration is that your keys exist only while the vault is unlocked - lock the database and they’re withdrawn. With gpg-agent serving SSH, that guarantee silently doesn’t hold. gpg-agent has no “remove this key” operation for KeePassXC to call, so locking your database doesn’t evict the key - it stays loaded and usable. Combined with the on-disk persistence above, a key you believe is gone can still be sitting in the agent - and on your filesystem - long after you’ve locked up.
There’s a visible “tell” for this, too: with gpg-agent owning the socket, unlocking a database often pops a pinentry dialog asking you to set a passphrase to protect the key gpg-agent is about to persist - sometimes vanishing again before you can even finish typing. An ephemeral agent never does this, because it has nothing to write to disk.
This isn’t new. Users have reported the consequences for years:
- #11589 - “GPG Agent: Does not remove ssh key from agent”: removal succeeds silently but the key remains;
ssh-add -Lstill lists it, and it survives a reboot. (Filed back on Ubuntu 23.04 - this is a trait of gpg-agent, not of any one release.) - #2243 - gpg-agent integration where the key isn’t reliably added either.
- #2902 - SSH keys not added consistently after each unlock.
So 26.04 didn’t create this incompatibility; on my machine the upgrade just coincided with gpg-agent’s socket ending up as the one that owned SSH_AUTH_SOCK.
If you rely on KeePassXC’s lock/unlock to control when your SSH keys are live, let an agent KeePassXC actually supports do that job - gcr-ssh-agent or a plain ssh-agent, both ephemeral and both happy to remove keys on request. There’s nothing wrong with gpg-agent; just keep it to your GPG keys, where it’s the right tool, and leave SSH-key lifetime to an agent built to hand keys back.
Summary
This one initially felt like an Ubuntu regression to me, but it wasn’t. On a modern GNOME desktop, several SSH agents compete to own SSH_AUTH_SOCK, and gpg-agent’s socket only joins that race when you’ve opted into enable-ssh-support. I had - years earlier, then forgot - so gpg-agent won, and because it persists SSH keys to disk it broke KeePassXC’s lock/unlock guarantee and left private-key copies on my filesystem.
Which makes the fix a one-liner: comment out enable-ssh-support, restart gpg-agent, and (one time only) clean up any keys it had already cached to disk. No masking, no environment overrides - just remove the setting that was pulling gpg-agent into the path, and the desktop’s own default (gcr-ssh-agent) takes over cleanly. Fully reversible, and you get your “keys only exist while the vault is unlocked” guarantee back.
And as the wider point above notes, this isn’t only about my forgotten setting: KeePassXC itself states that gpg-agent isn’t a compatible SSH agent for it, because gpg-agent can’t remove keys on request. So even if you integrate it deliberately, it can’t honour the lock/unlock guarantee - which is a good reason to leave SSH-key lifetime to gcr-ssh-agent or a plain ssh-agent and keep gpg-agent for your GPG keys.
Feedback
If this helped - or if you’ve untangled the same situation a different way - I’d love to hear about it. Please leave a comment or reaction below.
Cheers!
Edd
Got your KeePassXC agent working again? If this saved you an afternoon of digging, you can buy me a coffee. It’s about the price of, well, a coffee, takes 20 seconds, and needs no account. It genuinely makes my day.