Harden your developer setup with age and direnv
Credential stealing is rampant, so some diligence in guarding API keys, passphrases and secrets is warranted.
Stop wondering if it could happen to you, but prepare for when it might happen. Here is one arrow in your defense portfolio.
Limit the blast radius
When an attacker sneaks by your 2FA using e.g a supply chain attack your next line of defense is "How much can they see?"
- For starters no key (other than public ones), token or credential shall live on disk unencrypted.
- Loaded evn variables should be limited to their projects, when in
~/Code/someprojectthere must not be any env variable from~/Code/otherprojectbe loaded. - The day-2-day use must be easy and convenient
Three components
To make this work we need three components:
- macOS keychain: holds the AGE key(s), (Other operating systems is another story for another time).
- age "Actually good encryption": to encrypt your secrets and
.envvariables. - direnv: Loads and unloads environment variables depending on the current directory.
Step by step
I'm presuming you have Homebrew installed. YOu might want to check my macOS setup for this.
Step 0 - installation and layout
brew install age direnv
direnv needs to be hooked into zsh, so add one line to ~/.zshrc
eval "$(direnv hook zsh)"
Your directory layout should look like this. Note that a strongly suggest Code as root for your projects, keep them out of Documents unless you want various cloud syncs and git fight over them
| Directory | Purpose |
|---|---|
| ~/.config/age/ | key material (transient) |
| ~/.config/direnv/direnvrc | load_age helper |
| ~/Code/.envrc | global loader for all projects |
| ~/Code/.env.age | global secrets, for all projects |
| ~/Code/petproject/.envrc | personal project |
| ~/Code/petproject/.env.age | optional overrides |
| ~/Code/pro_team/.envrc | team project |
| ~/Code/pro_team/.env.age | team secrets (multi-recipient) |
| ~/Code/pro_team/recipients.txt | team public keys, committed |
the recipients.txt is needed for projects with more than one developer.
Step 1 - Personal identity (once)
You will generate your personal age key(s) and add them to the macOS keychain. I create two keys, one for personal and one for corporate use.
# Make keys
mkdir -p ~/.config/age
age-keygen -o ~/.config/age/personal_key.txt
age-keygen -o ~/.config/age/corporate_key.txt
# Add to keychain
security add-generic-password -s age-personal -a "$USER" -w "$(grep '^AGE-SECRET-KEY-' ~/.config/age/personal_key.txt)"
security add-generic-password -s age-corporate -a "$USER" -w "$(grep '^AGE-SECRET-KEY-' ~/.config/age/corporate_key.txt)"
# remove the keys
rm ~/.config/age/personal_key.txt
rm ~/.config/age/corporate_key.txt
You need to capture your public keys, I add them to my ~/.zshrc
export AGE_PERSONAL_PUBLIC=age1qr8xa48c38xwlv8jhtcallv2vy4nz5kg5928svtjl7nhxu6s5dusaq8ypz
export AGE_CORPORATE_PUBLIC=age1eh3arwjahag7hpg5salc7rzwwq5eagrqmvl3ck9jf6r3pn59vvvslvdlcr
!!! Warning Local only
The keychain entry does not sync with iCloud
Step 2 - helper (once)
Create the file ~/.config/direnv/direnvrc
load_age_personal() {
watch_file "$1"
eval "$(age -d -i <(security find-generic-password -s age-personal -w) "$1")"
}
load_age_corporate() {
watch_file "$1"
eval "$(age -d -i <(security find-generic-password -s age-corporate -w) "$1")"
}
Create the file ~/bin/editenv.sh (or somewhere on your $PATH). It allows you comfortably updating encrypted env files
#!/bin/bash
# editenv - decrypt .env.age, edit with nano, re-encrypt.
set -euo pipefail
ENC=".env.age"
PLAIN=".env"
RECIPIENTS="recipients.txt"
die() { echo "envedit: $*" >&2; exit 1; }
keychain_identity() {
local item="$1"
security find-generic-password -s "$item" -w 2>/dev/null \
|| die "Keychain item '$item' not found"
}
command -v age >/dev/null || die "age is not installed"
# Decrypt or create
if [[ -f "$ENC" ]]; then
if [[ -f "$RECIPIENTS" ]]; then
IDENTITY_ITEM="age-corporate"
else
IDENTITY_ITEM="age-personal"
fi
echo "Decrypting $ENC using Keychain item '$IDENTITY_ITEM'..."
age -d -i <(keychain_identity "$IDENTITY_ITEM") -o "$PLAIN" "$ENC" \
|| die "decryption failed"
else
echo "No $ENC found — starting with an empty $PLAIN"
touch "$PLAIN"
fi
chmod 600 "$PLAIN"
# Edit
nano "$PLAIN"
# Re-encrypt
if [[ -f "$RECIPIENTS" ]]; then
echo "Encrypting to recipients listed in $RECIPIENTS..."
age -e -R "$RECIPIENTS" -o "$ENC" "$PLAIN" || die "encryption failed"
else
PUBKEY="$(age-keygen -y <(keychain_identity "age-personal"))" \
|| die "could not derive personal public key"
echo "Encrypting to personal key $PUBKEY..."
age -e -r "$PUBKEY" -o "$ENC" "$PLAIN" || die "encryption failed"
fi
rm -f $PLAIN
echo "Done: $ENC updated."
If something goes wrong the .env file might be left behind, if you are confident you can add an exit trap right after keychain_identity()
# Never leave plaintext behind, whatever happens
cleanup() { rm -f "$PLAIN"; }
trap cleanup EXIT
Step 3 - Global secrets
Execute whenever they change:
cat <<'EOF' | age -e -r ${AGE_PERSONAL_PUBLIC} -o ~/Code/.env.age
export SOME_KEY="dop_v1_..."
export SOME_TOKEN="cf_..."
EOF
printf 'load_age_personal ~/Code/.env.age\n' > ~/Code/.envrc
cd ~/Code && direnv allow
Step 4a - personal projects
Create .envrc in your project root:
source_up
[[ -f .env.age ]] && load_age_personal .env.age
Step 4b - corporate projects
Create .envrc in your project root:
source_up
[[ -f .env.age ]] && load_age_corporate .env.age
Add recipients.txt file with all participants' public keys line by line. Then encrypt to all:
cat <<'EOF' | age -e -R recipients.txt -o .env.age
export DO_KEY="team-specific..."
EOF
Use the helper script editenv to comfortably edit the encrypted environment.
Next up
integration into Github actions, Yubikey and SSH
As usual YMMV
Posted by Stephan H Wissel on 21 August 2026 | Comments (0) | categories: Development GitHub macOS