Vid Bregar
Published on

Encrypt Environment Variables with GPG, YubiKey, and direnv

Authors
  • avatar
    Name
    Vid Bregar
    Twitter

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


Get notified when I post