Apple Keychain is a good secret store. It encrypts at rest, requires user authentication, and keeps credentials out of plaintext files and git history. macOS developers have used it for years, and it solves the problem it was designed for.
The problem is that Keychain was designed before AI coding agents existed. The pattern developers use to get secrets out of Keychain — the security CLI — outputs plaintext values to stdout. And Claude Code captures stdout.
The pattern everyone uses
If you’ve stored secrets in macOS Keychain, you’ve probably used the security command to retrieve them:
security find-generic-password -s "stripe" -a "dev" -wThe -w flag outputs the password — and only the password — to stdout. Plain text. This is the documented, intended behavior. It’s how you’re supposed to use it from the command line.
The common next step is to export that value as an environment variable:
export STRIPE_SECRET_KEY=$(security find-generic-password -s "stripe" -a "dev" -w)Developers put this in their .zshrc, their .bash_profile, or in a scripts/load-secrets.sh that they run before starting work. The Keychain stores the value securely. The export loads it into the shell environment. Apps that need it read from process.env. This pattern is widely recommended — there are tutorials on DEV Community, Medium, and countless personal blogs promoting it as the “right” way to keep secrets out of .env files on macOS.
It works fine. Until you add Claude Code to the picture.
What Claude Code sees
Claude Code is a shell agent. Its bash tool executes commands and returns stdout, stderr, and exit codes back to Claude’s context. This is not a bug — it’s the entire point of the tool. Claude runs a command, sees the output, and acts on it.
This creates a direct exposure path:
Path 1 — Direct execution. If Claude Code runs security find-generic-password -s "stripe" -a "dev" -w for any reason — debugging your environment, checking what’s configured, following instructions in a CLAUDE.md or shell script — the output is your Stripe secret key, printed to stdout, captured by the bash tool, returned to Claude’s context.
Path 2 — Environment inheritance. If you’ve already run export STRIPE_SECRET_KEY=$(security ...) before launching Claude Code, the key is in your shell environment. Claude Code inherits it. Running printenv, env, or echo $STRIPE_SECRET_KEY in any Claude Code session — whether Claude initiates it or you do — surfaces the value in the conversation.
Path 3 — Script execution. If you have a load-secrets.sh or similar that sources Keychain values into the environment, and Claude Code executes that script during project setup or debugging, all the secrets it exports flow through stdout and into Claude’s context.
Path 4 — Shell profile sourcing. If your .zshrc or .bash_profile contains export VAR=$(security ...) lines, any subprocess Claude Code spawns that sources your profile will run those commands. The values land in the subprocess environment and can be read from there.
The Keychain itself is never compromised. The encryption at rest is fine. The failure happens at the boundary where secrets leave Keychain and enter a runtime environment that an AI agent can observe.
The attack surface mapped out
| Layer | Secure? | Why |
|---|---|---|
| Keychain at rest | Yes | Encrypted, requires authentication |
security CLI stdout | No | Prints plaintext to stdout |
| Claude Code bash tool output | No | stdout returned to AI context |
| Shell environment after export | No | Claude Code inherits and can read env |
| Shell history | Partial | Command logged; value may appear too |
Scripts that call security | No | Execution output captured |
The top of this table is doing a lot of work and getting none of the credit. Keychain is secure. Everything below it — the way secrets travel from Keychain to your application — is the attack surface.
”But I use the Keychain prompt — doesn’t that help?”
When you first run security find-generic-password for a password, macOS may show a prompt asking whether to allow access. You can click “Always Allow” for your terminal application. Once you’ve done that, subsequent calls from that application happen without prompting.
If Claude Code runs inside the same terminal session — or any subprocess that inherits the terminal’s permissions — it runs under the same authorization. No prompt appears. The value is returned silently.
The Keychain prompt is a user-intent gate, not a per-process gate. Technically, macOS Keychain ACLs are per-item and per-application: “Always Allow” scoped to the security CLI works because security adds itself to the item’s ACL at creation time. A different binary accessing the same Keychain item would trigger a fresh confirmation prompt unless explicitly added to that item’s ACL. But within a terminal session, security is the binary doing the work — and once it has “Always Allow,” any process that invokes security gets the value back on stdout without prompting.
What about 1Password CLI?
op run is meaningfully different from security find-generic-password. Instead of printing secrets to stdout, op run injects them as environment variables into a specific child process:
op run -- node server.jsThe secret never touches stdout. It goes directly into the child process’s environment. The parent shell does not receive the values — op run scopes injection to the child process only. The remaining exposure surface is environment inheritance within the child process tree: grandchild processes inherit the environment, and on Linux /proc/self/environ exposes it. This is a narrower surface than parent-shell exposure, but it still exists.
This is a better pattern, and 1Password goes further: op run uses a PTY pair to intercept subprocess stdout and redact secret values if they appear in output. So if printenv or echo $STRIPE_KEY runs inside the subprocess, 1Password scrubs the value before it reaches the parent process. That’s a real control — not just injection, but active output filtering.
The remaining gap is about who controls the wrapper. With op run, the AI agent is the one invoking it. An agent that has been prompt-injected or manipulated can pass --no-masking, spawn a grandchild process that inherits the environment outside op run’s redaction scope, or on Linux read /proc/self/environ directly. The redaction protects against accidental leaks — it doesn’t protect against an agent that’s actively trying to surface the value.
The key question is: who controls the subprocess boundary?
The missing piece: subprocess injection at the broker level
The pattern that actually solves this is one where the AI agent is never the one initiating the credential retrieval. Instead, a local broker handles it:
Claude Code says: "run stripe charges list"OpaqueVault decrypts STRIPE_SECRET_KEY locallyOpaqueVault spawns the subprocess with the key in its environmentSubprocess runs, output capturedOutput scanned for credential patternsClaude Code receives the output — not the keyIn this model, the security CLI is never called in a context Claude Code can observe. The credential goes from the vault directly into the subprocess environment. Claude Code only sees what the subprocess printed — the API response, the exit code, the error message — not the credential that authenticated it.
This is what vault_run does in OpaqueVault. You store the credential once:
ov secret set STRIPE_SECRET_KEY# prompted for value — enters encrypted, never returned in plaintextWhen Claude Code needs to run an authenticated command, it calls vault_run instead of the command directly. OpaqueVault decrypts locally, injects into the subprocess, and returns only the output. The value never passes through Claude Code’s bash tool.
Migrating from Keychain
If you’re currently using the Keychain-to-env-var pattern, migration is straightforward. Your secrets are already stored somewhere — Keychain is just the store. OpaqueVault can replace it as the store:
# Pull value from Keychain, store in OpaqueVaultov secret set STRIPE_SECRET_KEY# enter the value when prompted — retrieve it from Keychain Access if neededThen remove the export VAR=$(security ...) lines from your shell profile. Your .zshrc gets simpler, not more complicated. The value is no longer in your shell environment at all — it only enters a subprocess environment for the duration of a specific command.
What Keychain is actually good for
Keychain is excellent for:
- App credentials — signing certificates, app passwords, SSH keys used by macOS apps
- Browser passwords — Safari integrates directly; passwords stay in Keychain, never stdout
- System credentials — Wi-Fi passwords, mail account passwords, disk encryption keys
- Short-lived authentication — things that need biometric confirmation per use
It’s not designed for the pattern of “developer retrieves via CLI, exports to shell, app reads from environment.” That pattern leaks the Keychain’s protection the moment the secret leaves it.
For AI-assisted development workflows specifically, Keychain’s CLI exposure surface makes it the wrong tool for the job. The encryption at rest is good. The egress model — stdout from a CLI command — is incompatible with an environment where an AI agent can observe shell output.
OpaqueVault is a zero-knowledge MCP secret manager for AI coding agents. Secrets are injected into subprocess environments by a local broker — Claude Code never sees the plaintext value. Get started free →
Related: Stop Telling Claude to Read Your .env File · How to Store Secrets for Claude Code
Corrections (April 2026):
- Qualified Keychain ACL behavior: “Always Allow” is per-item and per-application. The
securityCLI adds itself to the ACL at creation; other binaries trigger fresh prompts. (Apple Keychain Services docs) - Scoped
op runcritique: secrets are injected into the child process only, not the parent shell. Risk is grandchild inheritance and/proc/self/environ, not parent-shell exposure. (1Passwordop rundocs)