Scaling Secret Management with 1Password CLI
software development
Srđan Marković
CTO
Extending the macOS Keychain secret management pattern with 1Password CLI for cloud-synced secrets, a unified backend interface, and the op inject optimization for fast shell startup.
In Part 1, we moved plaintext API keys out of shell configuration files and into macOS Keychain. That eliminated the core vulnerability: AI coding agents reading raw credentials from dotfiles. It works well on a single machine.
But engineering teams rarely operate on a single machine. The Keychain backend has two practical limitations:
- No cross-machine sync. Every workstation needs its own manual provisioning. Add a new API key, and you run
_keychain_seton every machine individually. - No version history. Keychain items have no audit trail. If a key is overwritten or deleted, the previous value is gone.
We solved both by adding 1Password CLI (op) as a second backend. It provides a cloud-synced vault with version history, Touch ID authentication, and a batch injection mechanism that resolves all secrets in a single call.
The 1Password Backend
The architecture from Part 1 stays intact: _SECRETS array in .zshenv, loading loop in .zshrc. We add the 1Password functions alongside the existing Keychain ones.
First, a backend selector and vault configuration:
# ~/.zshenv - add alongside existing Keychain functions
export SECRET_BACKEND="op" # or "keychain"
# 1Password config (create vault: op vault create CLI --account my.1password.com)
export OP_ACCOUNT="my.1password.com" # or "yourcompany.1password.com"
export OP_VAULT="CLI"
Then the getter and setter, mirroring the Keychain pattern:
_op_get() {
local val
val="$(op item get "$1" --vault "$OP_VAULT" --fields password --reveal 2>/dev/null)" || {
echo "[secrets] Missing 1Password item: $1 in vault '$OP_VAULT', add with: _op_set $1" >&2
return 1
}
echo "$val"
}
_op_set() {
local secret="${2:-}"
if [[ -z "$secret" ]]; then
read -s "secret?Enter value for $1: "
echo ""
fi
if op item get "$1" --vault "$OP_VAULT" > /dev/null 2>&1; then
op item edit "$1" --vault "$OP_VAULT" "password=$secret"
else
op item create --vault "$OP_VAULT" --category Password --title "$1" "password=$secret"
fi
}
The interface is identical to the Keychain version. _op_set BRAVE_API_KEY prompts securely, stores the value in the vault, and syncs it across every machine connected to the 1Password account.
If you already have secrets in Keychain from Part 1, migrating them is a one-liner per key:
_op_set BRAVE_API_KEY $(_keychain_get BRAVE_API_KEY)
This reads the value from Keychain and pipes it directly into 1Password without the secret ever appearing on screen or in shell history.
The Unified Interface
With two backends, a unified entry point routes to the active one:
# ~/.zshenv - unified getter/setter
_secret_get() {
case "$SECRET_BACKEND" in
op) _op_get "$1" ;;
keychain) _keychain_get "$1" ;;
esac
}
_secret_set() {
case "$SECRET_BACKEND" in
op) _op_set "$1" "$2" ;;
keychain) _keychain_set "$1" "$2" ;;
esac
}
Now _secret_set NEW_API_KEY routes to the correct backend. An engineer switching from Keychain to 1Password changes one variable (SECRET_BACKEND="op") and re-provisions their secrets. The rest of the workflow is unchanged.
Figure 1: The unified interface routes to either 1Password or Keychain based on SECRET_BACKEND. The _SECRETS array and loading loop remain identical.
Performance: The op inject Optimization
A naive implementation loops through _SECRETS and calls op item get for each key. With 15 secrets, that is 15 separate CLI invocations, each requiring a round-trip to the 1Password app. In our testing, this made shell startup painfully slow.
The solution we found was op inject. It accepts a template on stdin with op:// references and resolves all of them in a single authenticated call:
# ~/.zshrc - resolve values at interactive shell startup
if [[ "$SECRET_BACKEND" == "op" ]]; then
_tpl=""
for _key in "${_SECRETS[@]}"; do
_tpl+="export ${_key}=\"op://${OP_VAULT}/${_key}/password\""$'\n'
done
eval "$(echo "$_tpl" | op inject)"
else
for _key in "${_SECRETS[@]}"; do
export "$_key"="$(_keychain_get "$_key")"
done
fi
One Touch ID prompt, one process invocation, all keys resolved instantly.
A technical nuance worth noting: op whoami does not trigger Touch ID, it only checks the existing authentication state. But op vault get and op item get do trigger Touch ID. This distinction matters if you are writing detection logic and want to avoid spurious biometric prompts.
The Cost
| Backend | Startup Time (15 keys) | Authentication | Best For |
|---|---|---|---|
Hardcoded .zshrc | ~0.0s | None | (Deprecated) |
| macOS Keychain | ~0.9s | Auto-unlocked on GUI login | Single-machine setups |
1Password (op inject) | ~1.5s | Touch ID per session | Multi-machine sync |
The extra 0.6s over Keychain buys you cloud sync, version history, and a single source of truth across every machine on the team. It is a one-time cost per terminal session, not per command.
What Comes Next
This setup gives you cloud-synced secrets with a single Touch ID prompt per session. One scenario remains: SSH access. This is increasingly common as developers run AI agents on powerful desktop machines and manage them remotely from laptops or mobile SSH clients like Termius.
The moment you SSH into your workstation, 1Password opens a Touch ID dialog on the machine’s physical display. Over SSH, you cannot tap it. The shell blocks indefinitely. The solution that worked for us was newly introduced 1Password Service Accounts with token-based authentication that bypasses biometrics entirely on remote connections (Part 3).