BACK

Moving Shell Secrets from .zshrc to 1Password CLI

5 min read

I keep my shell config in a dotfiles repo so I can track changes in git. But a few API keys needed to be available at shell startup, and hardcoding them in .zshrc wasn't an option. So I loaded them from a separate .env file — one that stayed out of the repo.

The secrets were still plaintext, just in a different file. One wrong git add and they'd be in history. Then I thought: 1Password could handle this.

I moved the keys there and now load them at shell startup with op inject — one call, biometric unlock, no plaintext files anywhere.

The problem with .env and .zshrc secrets

Most developers store secrets one of two ways: a .env file or export statements scattered across shell configs. Either way, the values are plaintext and end up in backups, dotfiles repos, or shell history.

I wrote about secure credential storage for Node.js with cross-keychain. Shell environment variables are a different problem — they load before any application runs, and every process in your terminal needs access to them.

1Password CLI (op) solves this at the shell level.

Create a vault and enable biometric unlock

Install the 1Password CLI first, then set up two things before touching your shell config.

A vault for the secrets. I created a vault called development with multiple entries for each service. Each secret is the credentials field for that service. This gives you clean op://development/<SERVICE_NAME>/credentials URIs. You can organize it however you like, but I recommend a consistent structure for easy reference.

Biometric unlock. Without this, op prompts for your master password every time you open a terminal. Enable it in the 1Password desktop app under Settings → Developer → "Integrate with 1Password CLI". After that, op authenticates via Touch ID or your system keychain.

Split your shell into two files

With the vault in place, replace your .env file with two shell files. One for non-secret configuration that zsh sources automatically. One for secrets that op inject processes before loading.

.zshenv — plain configuration, no secrets

export EDITOR="nvim"
export LANG="en_US.UTF-8"
export PATH="$HOME/.local/bin:$PATH"
export NODE_ENV="development"

Zsh sources this file automatically on every shell invocation — interactive, non-interactive, scripts, SSH commands. No secrets here, ever.

.zshsecrets — secret references, not values

export GITHUB_TOKEN="{{ op://development/github/credentials }}"
export ANTHROPIC_API_KEY="{{ op://development/anthropic/credentials }}"
export NPM_TOKEN="{{ op://development/npm/credentials }}"

It's safe to commit to your dotfiles repo. Anyone reading it sees op:// URIs, not credentials.

.zshrc — wire it together

if command -v op &>/dev/null; then
eval "$(op inject -i ~/.zshsecrets 2>/dev/null)" || echo "⚠ op: secrets not loaded"
fi

op inject reads the template, resolves every {{ op://... }} reference against your 1Password vault, and outputs the result with real values. eval executes the exports. If op isn't authenticated or unavailable, the shell still starts — you just won't have secrets loaded until you run op signin and re-source.

Why op inject over op read

Two ways to pull secrets from op. Per-variable reads:

export GITHUB_TOKEN="$(op read 'op://development/github/credentials' 2>/dev/null)"

Or template injection, which I use. The difference is performance. Each op read spawns a subprocess and makes an API call. With 5+ secrets, shell startup grows by 1–2 seconds. op inject resolves everything in a single call — around 200–400ms with biometric unlock enabled.

The cleanup you can't skip

After migrating, I did three things:

Removed every hardcoded secret from my shell files. A quick grep finds stragglers:

grep -rn 'export.*KEY\|export.*TOKEN\|export.*SECRET' ~/.zsh*

Scrubbed my dotfiles git history. If you ever committed secrets — even if you deleted them later — they're still in the history. git filter-repo or BFG Repo-Cleaner can purge them.

Rotated every token that had been in plaintext. Even if your secrets never touched a git repo, they may have lived in Time Machine backups or shell history. If you're not certain of your full exposure, rotate. It's not optional.

Tradeoffs

Startup latency. The op inject call adds 200–400ms to shell startup. Without biometric unlock, op prompts for your master password — painful if you open terminals frequently. Even with it enabled, the Touch ID prompt interrupts flow when opening terminals in quick succession.

Authentication gaps. If op isn't authenticated, your secrets won't load. You'll notice when a command fails, then run op signin and source ~/.zshrc. It's minor friction and a reasonable trade for no plaintext secrets.

No remote support. SSH sessions to remote machines won't have op. If you need secrets there, you'll need a different mechanism — op service accounts, or forwarding specific variables through SSH config.

A dotfiles repo you can make public

My dotfiles repo is now public-safe. Every secret lives in 1Password, a template file with op:// URIs loads them at startup, and Touch ID handles the rest.

If you need secrets on remote machines, the 1Password CLI docs cover service accounts and SSH agent forwarding.