Moving Shell Secrets from .zshrc to 1Password CLI
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; theneval "$(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.