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
.envrcignored 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.