Secure your .envrc with a YubiKey and GPG

I like .envrc because it keeps project configuration close to the project. I do not like .envrc when it becomes a little plaintext bucket of API keys, database URLs, seed phrases, blockchain private keys, and production credentials sitting on disk waiting for the next compromised dependency to read it.

This is not primarily about accidentally committing secrets to git. You should ignore .envrc anyway, but git hygiene is only part of the problem. Stealing local developer secrets has become a common attack vector: hacked open source packages, install scripts, fake CLIs, poisoned build steps, and “just run this repo” code that quietly scans the filesystem for keys.

The setup is simple: keep .envrc local and ignored by git, and make it load secrets from pass, encrypted with GPG, with the decryption key living on a YubiKey.


The security bit first

Do not rush your GPG key setup.

If this key protects development secrets, production credentials, or anything that can later become production credentials, create it in a clean and controlled environment. Use a dedicated machine or temporary offline environment if needed. Store backups deliberately. Test recovery. Know where your revocation certificate is.

The best guide I know for this is still drduh’s YubiKey Guide. Read it before creating the key.

Once the key is on the YubiKey, require touch for the encryption/decryption slot. With ykman, that is the enc slot:

ykman openpgp keys set-touch enc on

I usually prefer cached for day-to-day development:

ykman openpgp keys set-touch enc cached

Yubico documents cached as touch-required, cached for 15 seconds. That is a good compromise for tools that decrypt several values in a row. You still get a physical confirmation step without requiring one touch per decrypt operation inside that short cache window.

Use fixed or cached-fixed only if you really mean it. Those policies cannot be disabled later without deleting the private key from the slot.

Install pass

pass is the standard Unix password manager. It stores each secret as a GPG-encrypted file under ~/.password-store, with ordinary folders as structure.

Install it with your package manager:

brew install pass

or:

apt install pass

Then initialize it with your GPG key:

pass init YOUR_GPG_KEY_ID

You can find the key ID with:

gpg --list-secret-keys --keyid-format=long

Store the existing .envrc

Assume you already have a .envrc like this:

export OPENAI_API_KEY="sk-..."
export DATABASE_URL="postgres://..."

Insert the whole file into pass as a multiline secret:

pass insert -m dev/project/env < .envrc

That creates ~/.password-store/dev/project/env.gpg, encrypted to your GPG key. If your GPG private key is on the YubiKey, decrypting it requires the YubiKey.

Now replace the local .envrc with:

eval "$(pass show dev/project/env)"

The next time direnv loads the project, it asks pass for the encrypted environment file, GPG asks the YubiKey to decrypt it, and your shell evaluates the exported variables.

Important detail: eval executes whatever is inside that encrypted file. This is fine for a file you wrote yourself, but do not use this pattern with shared or untrusted entries unless you are happy to let them run shell code on your machine.

Another important detail: this protects the secret at rest. It stops filesystem scraping from reading .envrc as plaintext. It does not magically protect a secret after you export it into a shell environment. If malicious code runs inside a process that receives OPENAI_API_KEY, DATABASE_URL, or a blockchain private key as an environment variable, that code can read it.

For highly sensitive values, especially blockchain private keys, prefer not exporting them at all when you can avoid it. Use a hardware wallet, a signing service, short-lived credentials, or a command that asks pass for the secret only at the moment it is needed. Environment variables are convenient, but they are not a hardware security module.

At this point, .envrc no longer contains secrets:

eval "$(pass show dev/project/env)"

Add .envrc to .gitignore:

.envrc

Each engineer should keep their own .envrc setup. Mine usually points at my own pass paths, someone else’s might use different folders, different GPG recipients, or a different secret manager entirely. The repository does not need to know.

If an old .envrc was already committed with real secrets, rotate those secrets and clean the repository history. That is a separate problem from encrypting local secrets at rest.

One secret per variable

You can also encrypt each variable separately:

# .envrc
export OPENAI_API_KEY="$(pass show dev/project/OPENAI_API_KEY)"
export DATABASE_URL="$(pass show dev/project/DATABASE_URL)"

Then insert them one by one:

pass insert dev/project/OPENAI_API_KEY
pass insert dev/project/DATABASE_URL

I like this when different values have different lifetimes, owners, or sharing rules. It also makes rotation cleaner because you can update one value without rewriting the entire environment file.

The tradeoff is that each pass show is a separate decrypt operation. If your YubiKey touch policy is on, loading the project can require multiple touches. For that setup, set the YubiKey touch policy to cached at minimum:

ykman openpgp keys set-touch enc cached

The cache window is short, but it is enough for a .envrc that reads several secrets at once.

The result

You now have:

  • A local .envrc ignored by git
  • Secrets encrypted at rest instead of sitting in plaintext on disk
  • Secrets encrypted with GPG
  • Private key operations backed by a YubiKey
  • Optional touch confirmation before secrets are decrypted
  • A setup that still works with normal shell tools

This does not remove the need to rotate credentials, scope them correctly, avoid dumping them into logs, or think very hard before exposing blockchain private keys to random development processes. It removes one failure mode: plaintext secrets sitting on disk, ready for compromised code to copy.

3103 20A8 CC1C 5BA8 6AD0 9040 C045 1BAD F764 9BBF