11 May 2026, 00:00

Using YubiKey with age as encryption backend for gopass

Setting up gopass with a YubiKey on macOS

I have set up gopass as a work password store, backed by age encryption with a YubiKey holding the private key, and a git remote for sync. This is my notes about how I set the whole thing up

The goal

  • Password store managed by gopass
  • Secrets encrypted with age, private key on a YubiKey (via age-plugin-yubikey)
  • Store committed to git, synced to a remote repo
  • PIN + touch required to decrypt, nothing else

Architecture, briefly

There are two layers of encryption in this stack, and conflating them is the source of every confusion in this thread:

  1. Your secrets (foo.age files in the store) are encrypted with age to a YubiKey recipient (age1yubikey1...). The matching private key lives on the YubiKey hardware. Decryption requires PIN + touch.
  2. The gopass keyring file (~/.config/gopass/age/identities) holds the YubiKey identity stub (AGE-PLUGIN-YUBIKEY-...), which is just a pointer to a slot on a specific YubiKey serial. gopass encrypts this file with a passphrase (age scrypt mode), not with the YubiKey. The passphrase is unavoidable; macOS Keychain + Touch ID can make it invisible day-to-day.

The identity stub isn’t sensitive — useless without the physical YubiKey. The keyring encryption exists because gopass uses the same code path for plugin identities and native AGE-SECRET-KEY-... identities, and for the latter it genuinely matters.

Install

1
brew install gopass age age-plugin-yubikey

Generate the YubiKey identity

Plug in the YubiKey and run:

1
age-plugin-yubikey --generate --slot 1 --touch-policy cached --pin-policy once

Policy choices for a password store:

  • PIN policy once — PIN required once per session
  • Touch policy — I use cached — touch once, cached 15 seconds (convenient)

The command produces two strings:

  • An identity (AGE-PLUGIN-YUBIKEY-1...) — pointer to the YubiKey slot
  • A recipient (age1yubikey1...) — public key, used to encrypt secrets

Get them back any time with:

1
2
age-plugin-yubikey --identity   # shows the AGE-PLUGIN-YUBIKEY- line and the recipient
age-plugin-yubikey --list       # just the recipient

Backup matters

If this YubiKey breaks or is lost and it’s the only recipient, the store is unrecoverable. Before going further, decide on a backup recipient: a second physical YubiKey, or a passphrase-protected age key stored offline. Add it as an additional recipient in the next step.

Set up the gopass store

Do not redirect age-plugin-yubikey --identity into ~/.config/gopass/age/identities directly. gopass expects an encrypted keyring file there and will choke trying to “decrypt” the plaintext you wrote. Several guides online suggest this; it’s wrong for current gopass.

Instead, run:

1
gopass setup --crypto age --storage gitfs

When asked, choose to enter a passphrase (recommend y over the auto-generated one). This passphrase protects the local keyring file. Remember it — there’s no recovery.

gopass setup will also generate its own internal age keypair and add it to the keyring. We’ll deal with that in the next step.

Add the YubiKey identity to the keyring

1
gopass age identities add

It prompts for:

  1. Passphrase — the keyring passphrase you just set
  2. Age identity — paste the AGE-PLUGIN-YUBIKEY-1... string (the single line, not the # comment lines around it)
  3. Recipient — paste the matching age1yubikey1...

Verify:

1
gopass age identities

You’ll see two identities — the auto-generated one and the YubiKey one. This is the first footgun: both are now in the keyring, and gopass silently uses every identity as a recipient when encrypting new secrets. Your “YubiKey-only” store is anything but.

The critical part: remove the auto-generated identity

Check what your store is actually encrypting to:

1
gopass recipients

You’ll see both keys. Remove the non-YubiKey one:

1
2
3
gopass age identities remove age1.....     # the non-yubikey identity
gopass recipients remove age1......         # the non-yubikey recipient
gopass recipients update                       # re-encrypt existing secrets

To verify it actually worked, look at a secret file directly:

1
2
gopass insert test/check
cat ~/.local/share/gopass/stores/root/test/check.age | head -5

You want to see only a -> piv-p256 stanza in the age header. If you also see -> X25519, the auto-generated key is still encrypting alongside your YubiKey — your secrets can be decrypted by a soft key sitting in your home directory, which is exactly the threat model the YubiKey is meant to defeat.

This is the step the official docs do not emphasize, and it bit me hard. gopass insert and gopass show will happily succeed even when nothing is actually using the YubiKey, because the soft key works without prompting. You can go a long time thinking your YubiKey setup is live when it isn’t.

Set up the git remote

The store is already a git repo (gitfs backend). Add the remote and push:

1
2
gopass git remote add --store root origin git@github.example:you/secrets.git
gopass sync

gopass sync does pull → commit → push. With core.autosync = true (default), inserts and edits push automatically.

Verify the round-trip

1
2
gopass insert test/yk
gopass show test/yk

Expected:

  • YubiKey LED blinks (touch needed, since policy is cached and >15s since last touch)
  • PIN prompt on first decrypt of the session
  • Touch ID prompt (or passphrase) for the keyring — once per session if Keychain is enabled

If gopass show returns the value without the YubiKey blinking at all, go back to the verification step above and check the age header for a stray -> X25519 stanza.

Make the keyring passphrase invisible

Three options for handling the keyring passphrase:

1
2
3
4
5
6
7
8
# Option 1: macOS Keychain (Touch ID releases the passphrase)
gopass config age.usekeychain true

# Option 2: in-memory agent
gopass config age.agent-enabled true
gopass config age.agent-timeout 28800   # 8 hours

# Option 3: both

Option 1 is the most convenient on macOS — Touch ID replaces typing. Option 2 is the cross-platform answer. Since I’m only using a mac I went for option one.

Daily UX summary

After all this, day-to-day looks like:

  1. First gopass command in a session: Touch ID prompt (keychain releases the keyring passphrase)
  2. First decrypt of the session: YubiKey PIN prompt
  3. Each decrypt after 15s of inactivity: YubiKey LED blinks, touch it
  4. Rapid successive decrypts within 15s: no interaction needed
  5. Insert/edit: git push happens automatically; if SSH key is in Secretive or similar, expect one more Touch ID prompt for that

The SSH Touch ID prompt — if it appears — is from your SSH agent (Secretive in my case), not from gopass. It says “SecretAgent” and mentions ssh-needs-auth.

Lessons

  1. Don’t write the identities file by hand. Use gopass age identities add. The file is meant to be encrypted; pre-populating it with plaintext is the most-recommended-online and most-broken approach.
  2. Always inspect a real .age file after setup to confirm only -> piv-p256 stanzas are present. The CLI’s gopass recipients output is necessary but not sufficient — gopass silently includes its internal identity as a recipient if it’s in the keyring.
  3. The keyring passphrase is structural, not optional. Accept it and offload to Keychain or the agent.

Now I have a work password store I trust, with the actual security boundary on the YubiKey where I wanted it. Next: a separate private store mounted alongside, using a different YubiKey slot — same drill, but at least now I know where the trapdoors are.