Keep Secrets Away from Claude Code
Claude Code API key management needs more than a deny rule: grep and Python can still read .env. We reproduced the leak paths with a dummy key.
The standard way to keep Claude Code out of .env is a Read(./.env) deny rule. Some paths stay open anyway. We reproduced the ones the official docs themselves list as out of scope, using a dummy key, and then describe how to keep keys out of the agent’s reach. For how permission rules are evaluated, see Permission Design for AI Agents.
Setup
Linux test box, Claude Code 2.1.301, 2026-10-16. A temp directory and a shell started with env -i (empty environment). Every key shown is the dummy sk-dummy-0000-NOT-REAL. We did not observe Claude Code’s own allow/deny decisions inside a session; which paths are blocked comes from the docs. What we verified is that the unblocked paths really print the contents.
What deny covers, per the docs
From https://code.claude.com/docs/en/permissions (checked 2026-10-16): Read and Edit deny rules apply to built-in file tools, to file commands Claude Code recognizes in Bash (cat, head, tail, sed, tee) and to redirection targets. They don’t apply to commands that read files without naming them, such as grep -r pattern ., or to subprocesses that open files themselves, like Python or Node scripts. For OS-level enforcement, the docs point to the sandbox.
Reproduced with a dummy key
d=$(mktemp -d) && cd "$d"
env -i PATH=/usr/bin:/bin HOME="$d" bash --norc --noprofile
printf 'DUMMY_API_KEY=sk-dummy-0000-NOT-REAL\n' > .env
| Path | Command | Output |
|---|---|---|
| Search without naming the file | grep -r DUMMY_API_KEY . | ./.env:DUMMY_API_KEY=sk-dummy-0000-NOT-REAL |
| Python opens it | python3 -c "print(open('.env').read())" | DUMMY_API_KEY=sk-dummy-0000-NOT-REAL |
| Node opens it | node -e "...readFileSync('.env')..." | DUMMY_API_KEY=sk-dummy-0000-NOT-REAL |
| Inherited env | bash -c 'env | grep DUMMY' after export | DUMMY_API_KEY=sk-dummy-0000-NOT-REAL |
| Shell trace | bash -x -c 'curl -H "Authorization: Bearer $DUMMY_API_KEY" ...' | + curl ... 'Authorization: Bearer sk-dummy-0000-NOT-REAL' ... |
set -x prints the expanded key into the trace, which lands in logs and in the agent’s context. Blocking a file and hiding a key are different jobs. Deny only does the first.
Environment variables reach subprocesses
Keys exported in the launching shell flow to Bash commands, hooks and stdio MCP servers. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 strips credentials from those subprocesses, matching by variable name or value shape; GITHUB_TOKEN and secrets that don’t look like credentials remain, so the docs say to pair it with narrow permission rules (https://code.claude.com/docs/en/env-vars, checked 2026-10-16). Hook processes inherit the parent environment too (https://code.claude.com/docs/en/hooks, checked 2026-10-16).
When a variable you set does not take effect at all, see Claude Code Env Vars Not Working.
Close it at the OS level
The sandbox restricts file reads and network for Bash commands and their children; filesystem.denyRead covers credential files like ~/.ssh (Seatbelt on macOS, bubblewrap on Linux/WSL2). denyRead does not stop the built-in Read tool, so set both (https://code.claude.com/docs/en/sandboxing, checked 2026-10-16). A PreToolUse hook can block commands mentioning .env (Claude Code Hooks in Practice), but it can’t see grep -r ..
The trailing newline
Some commands that fetch a key from an OS credential store end with a newline. We stored one as a secret as is; the key saved with the newline, and a notification path failed every time without telling anyone. With the dummy:
get(){ printf 'sk-dummy-0000-NOT-REAL\n'; }
get | wc -c # 23 bytes, newline included
s="$(get)"; printf %s "$s" | wc -c # 22 bytes
Piping keeps the newline; $(...) drops it. Check the byte count before you store a secret.
Show the agent aggregates only
When an agent inspects data with personal information, select non-personal columns and output counts and totals, never rows; transcripts keep everything. Before anything leaves the machine (web fetches, issue bodies, review requests to another model), check the files contain no secrets. Review requests are the path people forget.
Don’t put the key there
Best is no plaintext key where the agent runs. 1Password CLI’s op run, for example, resolves op:// references from a .env file at runtime and passes them as environment variables only to the subprocess it starts (https://developer.1password.com/docs/cli/secrets-environment-variables/, checked 2026-10-16). The same page warns that processes run by the same user can read each other’s environment. Inside that process, the key is an ordinary variable. For keeping a bot token out of the LLM’s context, see Run an AI Agent 24/7 on a VPS.
Summary
Read(./.env)deny doesn’t covergrep -r .or Python/Node scripts, as the docs say.- Keys also leak through
set -xtraces and inherited environment variables. - Set both permission deny and sandbox
denyRead; considerCLAUDE_CODE_SUBPROCESS_ENV_SCRUB. - Check byte counts for trailing newlines when storing keys.
- Show agents aggregates, and keep plaintext keys out of their environment.