The path of least resistance for giving coding agents API keys is a .env file in your home directory. It works. It's also a plaintext file that any process on the machine can read, sitting in every backup, with no way to say "not right now".
I keep agent secrets in Bitwarden (self-hosted Vaultwarden, in my case) with a small wrapper in front of it.
Store: one secure note, one hidden field per variable
Rather than one vault item per key, I use a single secure note called agent-env with a hidden custom field for each variable. One bw get item call fetches the lot, which matters because the bw CLI isn't fast. Adding a secret is just adding a field in the web vault.
bw get template item | jq --argjson f "$fields" '
.type = 2 | .secureNote = {type: 0} | .login = null
| .name = "agent-env" | .fields = $f' | bw encode | bw create item
Where $fields is your variables as [{name, value, type: 1}] (type 1 is a hidden field). Verify the round trip by comparing values in jq and printing only ok/MISMATCH, so the secrets never hit your terminal.
Use: inject into one command, never print
Agents don't read secrets. They run commands with them:
withsecrets OPENAI_API_KEY -- python summarise.py # just the one it needs
withsecrets -- npm run deploy # everything
withsecrets --list # names only
The core of withsecrets is short:
json=$(bw get item agent-env --nointeraction)
unset BW_SESSION # the child never gets vault access
while IFS= read -r -d '' k && IFS= read -r -d '' v; do
export "$k=$v"
done < <(jq -j --args '.fields[]
| select(($ARGS.positional|length)==0 or (.name as $n | $ARGS.positional|index($n)))
| .name, "\u0000", .value, "\u0000"' "${vars[@]}" <<<"$json")
exec "$@"
NUL-separated output means values with spaces, quotes or = survive intact, and exec means the secrets only live in that one process's environment, not in the agent's transcript.
Access: I hold the lock
The vault session key is what makes bw get work. I park it in the macOS Keychain with two tiny scripts:
# bw-agent-unlock — I run this, agents don't
session=$(bw unlock --raw)
security add-generic-password -U -a "$USER" -s bw-agent-session -w "$session"
# bw-agent-lock — revoke everything
bw lock
security delete-generic-password -a "$USER" -s bw-agent-session
withsecrets reads the key from Keychain. When I lock, it fails with "ask the user to unlock", and the agent stops and asks me. Unlocking needs my master password, so an agent can't do it for itself.
Guardrails: make the wrapper the only door
In Claude Code's ~/.claude/settings.json I deny the direct routes, so agents have to go through withsecrets:
"permissions": {
"deny": [
"Bash(bw get:*)", "Bash(bw list:*)", "Bash(bw export:*)", "Bash(bw unlock:*)",
"Bash(security find-generic-password:*)", "Read(~/.env)"
]
}
What this doesn't do
An agent that can run arbitrary commands could still run withsecrets -- env. If a process can use a secret, a determined agent can see it. What you do get is no plaintext at rest, least-privilege by default, and an off switch.
The other catch: a Bitwarden session key unlocks the whole vault, not just one note. Lock it when you're not working. Or, better, put agent secrets in a separate account and point the CLI at it with BITWARDENCLI_APPDATA_DIR, so a leaked session exposes those keys and nothing else.
If you're moving keys out of a .env, rotate anything you care about afterwards. Deleting the file doesn't delete it from your backups.