- Published on
Encrypt Environment Variables with GPG, YubiKey, and direnv
- Authors

- Name
- Vid Bregar
It's very common to see secrets in plain text .env files:
AUTH_TOKEN="..."
API_KEY="..."
PRIVATE_KEY="..."
It’s simple and convenient, but those secrets are sitting unencrypted on disk the whole time. They're just waiting for an explot to happen, and we've seen plenty of attacks recently, from Sahi-Hulud to axios.
I wanted something a bit better without making development annoying.
So I switched to storing my secrets encrypted with GPG, protected by a YubiKey, and automatically loading them with direnv only when I cd to the repository or when I explicitly require them.
Of course, the idea presented here does not guard against any possible attack, but it is a step in the right direction.
The result is:
- secrets are encrypted at rest
- decryption requires YubiKey touch (proving physical presence)
- secrets automatically load into the shell when entering the repo or when running
direnv allow - secrets automatically unload when leaving it or when running
direnv deny
Still convenient, but much safer than plain text .env files.
Prerequisites
Installed direnv.
Additionally, you need a proper GPG + YubiKey hardware key. This guide is excellent: https://github.com/drduh/yubikey-guide
Technically, you could set up the same development workflow without a YubiKey, but it would be less secure (and probably less convenient as well).
Setup
Make sure you add the following in your .gitignore as a precaution (only .env can potentially contain secrets, but that file should not exist anyway):
.env
.env.gpg
.envrc
Create your plain text .env first (you can include .env.example in git for convenience) and fill it out:
cp .env.example .env
Encrypt it:
gpg --encrypt --recipient <your_gpg_key_fingerprint> .env
chmod 600 .env.gpg
Now that you have the encrypted .env.gpg you can delete .env.
Next, we need to integrate with direnv to conveniently decrypt and load the variables on the fly. For that, we need to add the .envrc script. You can include .envrc.example in git so others can easily bootstrap the setup, while still keeping full control over their final .envrc configuration.
cp .envrc.example .envrc
Content of the .envrc should be something like:
#!/usr/bin/env bash
# =============================================================================
# SECURE ENV LOADER (direnv + GPG + YubiKey)
# =============================================================================
#
# Loads environment variables from encrypted .env.gpg using GPG.
# Designed for use with direnv: variables auto-load on entering the repo
# and auto-unload when leaving it.
# If properly configured, decryption requires
# YubiKey physical touch (and occasionally PIN).
_log_error() {
echo "[-] Error: $1" >&2
exit 1
}
# Dependency and file check
command -v gpg >/dev/null 2>&1 || _log_error "gpg is not installed."
SECRET_FILE=".env.gpg"
[[ ! -f "$SECRET_FILE" ]] && _log_error "$SECRET_FILE not found."
# Ensure the encrypted file isn't world-readable
case "$(uname)" in
Linux) PERMS=$(stat -c "%a" "$SECRET_FILE") ;;
Darwin) PERMS=$(stat -f "%Lp" "$SECRET_FILE") ;;
*) _log_error "Unsupported OS." ;;
esac
if [[ "$PERMS" != "600" && "$PERMS" != "400" ]]; then
_log_error "Insecure permissions ($PERMS) on $SECRET_FILE. Run: chmod 600 $SECRET_FILE"
fi
# Decrypt Secrets
echo "[+] Decrypting secrets from $SECRET_FILE..."
echo "[!] Touch your YubiKey when it blinks."
DECRYPTED_VARS=$(gpg --decrypt --quiet "$SECRET_FILE" 2>/dev/null)
if [[ $? -ne 0 || -z "$DECRYPTED_VARS" ]]; then
_log_error "Decryption failed or file is empty. Check YubiKey/PIN."
fi
# We assume .env.gpg is not malicious (we've created it)...
while read -r line || [[ -n "$line" ]]; do
# Skip comments and empty lines
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
key="${line%%=*}"
value="${line#*=}"
# Clean up
key=$(echo "$key" | xargs)
key=${key#export }
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
if [[ "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
export "$key=$value"
fi
done <<<"$DECRYPTED_VARS"
unset DECRYPTED_VARS
echo "[+] Environment loaded."
Lastly, if you've setup up everything correctly, you can run direnv allow, touch YubiKey when prompted (and potentially enter PIN beforehand) and see that the environment variables have been loaded in your current shell. Should you want to unload the variables, simply run direnv deny.
Done.
Final Thoughts
Now your secrets stay encrypted on disk and are only decrypted when you enter the directory and verify physical presence via your YubiKey.
A useful direction for future improvement would be to decrypt only the specific secrets that are needed, and only at the moment they are required. For example:
- run terraform apply
- touch YubiKey
- load only required secrets
- create plan and execute
- unload secrets
However, even with the current setup, although more tedious, it's possible to run direnv allow only right before you need a secret, and afterward run direnv deny.
Need help securing or improving your cloud infrastructure and development workflows?
Let's connect