17 October 2026

Giving Agents Secrets Without a .env

A plaintext ~/.env is the easiest thing for a coding agent to read and the hardest to take back. Here's how I keep agent secrets in Bitwarden instead, with a scoped, lockable way to use them.

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.