Three crystalline blue padlocks connected by golden threads with fingerprint sensor on a grid background

Scaling Secret Management with 1Password CLI

software development

March 2026.
Srđan Marković's profile image

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_set on 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.

Dual-backend secret management architecture

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#

BackendStartup Time (15 keys)AuthenticationBest For
Hardcoded .zshrc~0.0sNone(Deprecated)
macOS Keychain~0.9sAuto-unlocked on GUI loginSingle-machine setups
1Password (op inject)~1.5sTouch ID per sessionMulti-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).

Share