Crystalline blue padlock with circuit board internals and fingerprint sensor on a grid background, symbolizing secure secret management

Secret Management in the Age of AI Coding Agents

software development

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

Srđan Marković

CTO

A practical architecture for securing developer credentials against AI agent context ingestion, using a zero-trust shell configuration pattern with macOS Keychain or 1Password CLI backends.

The rapid adoption of AI coding agents from Claude, Gemini, Codex, and others has changed the threat model of the developer environment. When we grant these agents terminal access, we are implicitly allowing them to read our shell configuration files. The .zshrc, .zshenv, .zprofile, .bashrc, or .profile are no longer just local setup scripts, they are part of the context window.

If your engineering team’s API keys, database passwords, and infrastructure tokens are hardcoded in those files, they are now part of the prompt.

This is not a theoretical risk, it is the default state of most developer environments today. A quick audit of any engineer’s dotfiles will likely surface sensitive credentials sitting in plain text: exported as environment variables, committed to dotfiles repositories, and synced across machines.

The traditional argument for hardcoding keys in shell configurations was convenience. They are always available, they survive restarts, and they require zero setup. That argument no longer holds when your shell config is routinely ingested by third-party AI systems. As engineering leaders, we need an architecture that removes plaintext secrets from the environment without destroying developer velocity.

1. The Exposure Surface#

Here is what a typical .zshrc or .zshenv looks like in the wild:

export BRAVE_API_KEY="BSAx_g0_XUZ...ue5"
export CONTEXT7_API_KEY="ctx...7da"
export ELEVENLABS_API_KEY="sk_33c...f38"
export HF_TOKEN="hf_rAf...lzU"
export KAGGLE_API_TOKEN="KGAT_09c...7b4"
export MATHPIX_APP_KEY="4eb...366"

This file is:

  • Read by every AI coding agent that has terminal access.
  • Committed to git if the engineer uses a dotfiles repository.
  • Synced to multiple machines manually or via backup tools.
  • Visible in process memory to any tool that reads environment variables.

The exposure surface is large, and it grows with every new AI tool integrated into the workflow.

2. The Solution: Zero-Trust Shell Config#

The fix is straightforward in principle: your shell config should reference keys, not contain them. The actual values must live in a secure store like macOS Keychain, 1Password, or an equivalent key manager, and be resolved only at interactive shell startup.

On macOS, two backends are immediately available for this:

  1. macOS Keychain: The built-in store. Fast, requires no external dependencies, and integrates tightly with the OS login system. However, it lacks version history and sync capabilities.
  2. 1Password CLI (op): A robust, cloud-synced password manager with a CLI interface. It maintains version history, authenticates via Touch ID, and synchronizes easily across a developer’s fleet of machines.

The Architectural Split: .zshenv vs .zshrc#

During our internal implementation, we learned a critical lesson about timing. The .zshenv file runs for every shell invocation, including non-interactive subshells, script executions, and IDE background processes. It runs far too early for biometric prompts or Keychain lookups to work reliably. When we initially placed our loading logic there, Touch ID dialogs appeared unpredictably, and Keychain access failed silently in non-interactive contexts.

The solution is a clean architectural split:

  • .zshenv is purely declarative. It defines the _SECRETS array, helper functions, and the chosen backend config. No values, no I/O, no auth prompts.
  • .zshrc is the executor. It runs only for interactive shells, where biometric prompts and password dialogs are appropriate. This is where the actual loading and injection occurs.
Secret management architecture

Figure 1: The split architecture: .zshenv defines names and functions, while .zshrc resolves values from the appropriate backend based on the session type.

# ~/.zshenv -- helper functions and key names (no values)

export SECRET_BACKEND="keychain"  # or "op"

# 1Password config (create vault using: op vault create CLI --account my.1password.com)
export OP_ACCOUNT="my.1password.com"  # or "yourcompany.1password.com"
export OP_VAULT="CLI"

_SECRETS=(
  BRAVE_API_KEY
  CONTEXT7_API_KEY
  ELEVENLABS_API_KEY
  HF_TOKEN
  MATHPIX_APP_KEY
  KAGGLE_API_TOKEN
  # ... add new entries here
)

# --- macOS Keychain ---
_keychain_get() {
  local val
  val="$(security find-generic-password -a "$USER" -s "$1" -w 2>/dev/null)" || {
    echo "[secrets] Missing keychain item: $1, add with: _keychain_set $1" >&2
    return 1
  }
  echo "$val"
}

# --- 1Password CLI ---
_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"
}

# --- Unified interface ---
_secret_get() {
  case "$SECRET_BACKEND" in
    op)       _op_get "$1" ;;
    keychain) _keychain_get "$1" ;;
  esac
}

When an AI agent reads this file, it sees function definitions and an array of names. It never sees the raw credential.

3. The Path of Least Resistance#

Security models fail when they introduce too much friction. If storing a key requires five manual steps, engineers will revert to hardcoding. The helper functions must make the secure path the easiest path.

_keychain_set() {
  if security find-generic-password -a "$USER" -s "$1" > /dev/null 2>&1; then
    echo "WARNING: Keychain item '$1' already exists. This will overwrite it permanently (no undo)."
    read -q "?Overwrite? [y/N] " || { echo ""; return 1; }
    echo ""
    security delete-generic-password -a "$USER" -s "$1" > /dev/null 2>&1
  fi
  local secret="${2:-}"
  if [[ -z "$secret" ]]; then
    read -s "secret?Enter value for $1: "
    echo ""
  fi
  security add-generic-password -a "$USER" -s "$1" -w "$secret"
}

_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
}

# Unified setter (uses active backend)
_secret_set() {
  case "$SECRET_BACKEND" in
    op)       _op_set "$1" "$2" ;;
    keychain) _keychain_set "$1" "$2" ;;
  esac
}

Adding a new key is now exactly two steps:

  1. Run _secret_set NEW_API_KEY (which prompts for the value securely).
  2. Add NEW_API_KEY to the _SECRETS array in ~/.zshenv.

Critically, we use read -s for the prompt. This suppresses terminal echo, ensuring the plaintext value never appears on screen, in the shell history (.zsh_history), or in ps output. The optional second parameter even allows piping for bulk, non-interactive migrations (e.g. _keychain_set BRAVE_API_KEY $(_op_get BRAVE_API_KEY)).

4. Performance at Scale: The op inject Paradigm#

For 1Password users, a naive implementation loops through _SECRETS and calls op item get for each key. If an engineer has 15 secrets, this triggers 15 separate CLI invocations, each requiring a heavy round-trip to the 1Password app. In our testing, this made shell startup painfully slow.

The solution is op inject. It accepts a template on stdin with op:// references and resolves all of them in a single, highly optimized, 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

This yields one Touch ID prompt, one process invocation, and instant resolution of all keys.

A technical nuance to remember: op whoami does not trigger Touch ID, it only checks the existing authentication state. However, op vault get and op item get do trigger Touch ID. This distinction is vital if you are writing background detection logic and want to avoid spurious biometric prompts.

5. Benchmarking the Cost of Security#

Moving away from a hardcoded baseline introduces unavoidable I/O and cryptographic latency. A hardcoded .zshenv adds roughly ~0.0s to shell startup. Here is the benchmark for injecting 15 keys dynamically:

BackendStartup Time (15 keys)AuthenticationBest For
Hardcoded Baseline~0.0sNone(Deprecated)
macOS Keychain~0.9sAuto-unlocked on GUI loginSingle-machine setups
1Password (op inject)~1.5sTouch ID per sessionMulti-machine sync

While ~0.9s or ~1.5s of latency is noticeable, it is a negligible price to pay to definitively remove the risk of credential leakage into LLM context windows.


Part 2: The SSH Complexity#

Part 1 is elegant and takes an engineer about 20 minutes to set up. It works flawlessly for local GUI terminal sessions. But the moment you SSH into your workstation remotely, the architecture encounters the deliberate security boundaries of macOS:

  1. Biometric Blocking: 1Password Touch ID opens a biometric dialog on the machine’s physical display. Over SSH, you cannot tap it. The shell blocks indefinitely.
  2. Keychain Isolation: macOS Keychain unlock state is per-login-session. Unlocking the keychain from a local GUI terminal does not carry over to SSH sessions. There is no PAM module, no sshd_config option, and no launchd mechanism to auto-unlock the Keychain on SSH login.
  3. Password Prompts: Over SSH, security find-generic-password fails silently unless the keychain is explicitly unlocked.

These are not bugs, they are intentional, highly effective security constraints designed to prevent remote exfiltration. But they break automated startup workflows.

The Solution: 1Password Service Accounts#

The most robust solution for remote access is leveraging 1Password Service Accounts. These use token-based authentication that requires no biometric input and no interactive password, providing read-only access to a specific vault.

The strategy is to provision the token locally and store it in a highly restricted file:

# One-time setup per machine (from local GUI terminal):
# 1. Sign in to 1Password.com and click "Developer Tools" > "Service Accounts".
# 2. Click "Create a service account" and name it (e.g., "CLI SSH Access").
# 3. Grant it "Read" access to your "CLI" vault.
# 4. Copy the generated token, then run the snippet below:
mkdir -p ~/.config/op
read -s "token?Enter 1Password Service Account Token: "
echo
echo "$token" > ~/.config/op/.sa-token
chmod 400 ~/.config/op/.sa-token
unset token

Then, in .zshrc, we detect the SSH connection and inject the secrets using this token:

# ~/.zshrc -- The final hybrid loading logic

if [[ -n "$SSH_CONNECTION" ]]; then
  if [[ -r ~/.config/op/.sa-token ]]; then
    export OP_SERVICE_ACCOUNT_TOKEN="$(cat ~/.config/op/.sa-token)"
    _tpl=""
    for _key in "${_SECRETS[@]}"; do
      _tpl+="export ${_key}=\"op://${OP_VAULT}/${_key}/password\""$'\n'
    done
    eval "$(echo "$_tpl" | op inject)" 2>/dev/null || _SECRETS=()
  else
    # Fallback: unlock login Keychain (prompts for password once)
    if security unlock-keychain ~/Library/Keychains/login.keychain-db 2>/dev/null; then
      for _key in "${_SECRETS[@]}"; do
        export "$_key"="$(_keychain_get "$_key")"
      done
    fi
    _SECRETS=()
  fi
elif [[ "$SECRET_BACKEND" == "op" ]]; then
  # Local + 1Password
  _tpl=""
  for _key in "${_SECRETS[@]}"; do
    _tpl+="export ${_key}=\"op://${OP_VAULT}/${_key}/password\""$'\n'
  done
  eval "$(echo "$_tpl" | op inject)"
else
  # Local + Keychain
  for _key in "${_SECRETS[@]}"; do
    export "$_key"="$(_keychain_get "$_key")"
  done
fi

The resulting matrix is robust:

  • Local + Keychain: Loops _SECRETS, fast ~0.9s load, zero prompts.
  • Local + 1Password: Single op inject, one Touch ID prompt per session, ~1.5s load.
  • SSH + Service Account: Uses the chmod 400 token, bypasses biometrics entirely, fast load, zero prompts.
  • SSH + Keychain (Fallback): Prompts for the user’s macOS password once per SSH login, then loads normally.

6. Migration Checklist#

For teams looking to roll this out, the migration takes roughly 30 minutes per engineer:

Phase 1: Inventory & Triage

  • grep -s "^export.*=.*['\"]" ~/.zsh* ~/.bash* 2>/dev/null - Identify all hardcoded variables.
  • Differentiate between actual secrets (API keys, tokens) and basic configuration (paths, local DB DSNs, feature flags). Only secrets need to be migrated.

Phase 2: Provisioning

  • Select a backend: keychain for isolated machines, op for multi-machine synchronization.
  • Run _secret_set KEY_NAME for each secret.
  • Verify ingestion with _secret_get KEY_NAME.

Phase 3: Refactoring Configs

  • Add the _SECRETS array and helper functions to ~/.zshenv.
  • Add the loading logic block to ~/.zshrc.
  • Delete all hardcoded export KEY="value" lines.

Phase 4: Validation & Cleanup

  • Open a new terminal pane and verify printenv KEY works.
  • Scrub historical dotfiles backups. If the dotfiles were ever stored in a public repository, you must rotate the exposed keys.

7. What About Linux?#

While the underlying backends described here are native to macOS, the architectural pattern (splitting definitions into .zshenv and resolving them dynamically in .zshrc via an array) is pure POSIX-compliant shell logic. It is entirely portable.

On Linux, the _keychain_get helper is easily swapped for pass (GPG-encrypted), gnome-keyring, or secret-tool (libsecret). 1Password’s CLI (op) works identically on Linux, making it the most pragmatic, unified choice for engineering teams that span both operating systems.

The End of Plaintext#

The era of hardcoded API keys in shell configuration files is over. While it was never a best practice, the risk was previously confined to a laptop being stolen. Today, with the proliferation of AI coding assistants, those files are actively, continuously read by software that relies on massive context windows and external network access.

The architectural shift requires under 100 lines of shell logic. It maintains exactly the same developer experience. printenv works, scripts execute flawlessly, and AI agents have the necessary environment variables available in runtime memory, but the structural vulnerability is eliminated. Your credentials remain in a secure store, completely invisible to the code assistants operating in your terminal.

That is worth 100 lines of shell.

Share