How to Stop Claude Code From Reading Your .env File (Deny Rules Aren't Enough)
Claude Code doesn't ask before reading files in your project. Anthropic's own permissions table says it: read-only tools like file reads and Grep need "No" approval within the working directory. Your .env sits in the wor
Claude Code doesn't ask before reading files in your project. Anthropic's own permissions table says it: read-only tools like file reads and Grep need "No" approval within the working directory. Your .env sits in the working directory. So unless you've changed something, the agent can open it, put your Stripe key into its context, and quote it back in a debug log without ever asking.
.gitignore does nothing here. It only keeps the file out of commits. The agent reads straight from disk.
There's a fresh reason to care. On October 30, Phorion published an advisory about a backdoored "Claude Code for VS Code" build on the Cursor Marketplace, versions 2.1.312 and 2.1.313. It added an attacker's SSH key to ~/.ssh/authorized_keys, opened a reverse tunnel, and beaconed home with up to 40 environment variable names matching KEY|TOKEN|SECRET|PASS|AWS|GCP. On Hacker News, Anthropic's Felix Rieseberg said "it wasn't a compromise of Anthropic's systems or employee laptops." I don't much care whose fault it was. Every file your tooling can read is one bad update away from leaving your machine.
Below is my setup. The deny rule most guides stop at is only the first step, because Bash can still read the file around it.
Block Claude Code from reading .env with permissions.deny
Put this in ~/.claude/settings.json so it applies to every project:
{
"permissions": {
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(**/secrets/**)",
"Read(~/.ssh/**)",
"Read(~/.aws/**)"
]
}
}
The first two rules handle .env. The other three are cheap to add while you're in there.
The path syntax trips people up. The docs' own example uses Read(./.env), which matches "the .env file in the current directory". A bare Read(.env) follows gitignore semantics and matches any .env at or under the current directory, so apps/api/.env in your monorepo is covered too. Also, don't put Read(/secrets/**) in user settings. There, a leading / anchors to ~/.claude, so it would block ~/.claude/secrets/** and leave your project alone.
What these rules cover, per the docs:
- the Read tool, plus a best-effort pass over Grep, Glob,
@filementions and the open-file context the IDE extension shares - Edit and Write on the same path (Write needs v2.1.228+)
- Bash commands Claude Code recognizes as file commands:
cat,head,tail,sed,tee, and redirections like< .env(input redirects are checked from v2.1.257,teetargets from v2.1.269)
If you still have a .claudeignore file lying around, the docs say flatly that "it has no effect". Move its entries into deny rules.
Run /status afterwards and check that User settings shows up under Setting sources.
What deny rules don't cover: Bash can still read your secrets
Most "just add a deny rule" posts skip this part. According to the permissions warning box, deny rules don't apply to:
a command that reads files without naming them, such as
grep -r pattern .run from the directory that holds the file, or to arbitrary subprocesses that read or write files indirectly, like a Python or Node script that opens files itself.
So cat .env gets blocked, but grep -r DATABASE_URL . or node -e "require('dotenv').config(); console.log(process.env)" doesn't. Nobody has to be malicious for this to happen. An agent debugging a connection error will try exactly those commands.
Close the Bash hole with the sandbox
This has to be enforced by the OS. Claude Code's sandbox uses Seatbelt on macOS and bubblewrap on Linux and WSL2, and the docs say Read and Edit deny rules get "merged into the final sandbox configuration". With it on, the kernel stops grep -r and that Node one-liner, where before they slipped past a string matcher.
{
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"allowUnsandboxedCommands": false,
"filesystem": {
"denyRead": ["~/.ssh", "~/.aws"]
}
}
}
Why the two less obvious keys are in there:
- By default, if the sandbox can't start, Claude Code "runs commands without sandboxing". That fails open.
failIfUnavailable: truemakes it exit instead. - When a sandboxed command fails, Claude may retry it with
dangerouslyDisableSandbox, and inbypassPermissionsmode that retry runs without a prompt.allowUnsandboxedCommands: falseturns the retry off.
The sandbox has two limits. Its default read scope is "most of the machine, including credential files such as ~/.ssh and ~/.aws/credentials", so you have to list what you want hidden. And on native Windows, commands run unsandboxed, full stop. Use WSL2.
If you're on v2.1.257 or later, also set "blockReadsOutsideWorkingDirectories": true under permissions. It makes Read, Grep, Glob and LSP refuse paths outside your project in every mode, including bypassPermissions.
A PreToolUse hook as a tripwire
I still run a hook with the sandbox on. You get a clear message in the transcript, and it works on machines where you can't sandbox. Save this as .claude/hooks/block-secrets.sh and chmod +x it:
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')
if printf '%s' "$cmd" | grep -Eq '\.env([^A-Za-z]|$)|secrets/|\.ssh/|id_(rsa|ed25519)|\.aws/credentials'; then
echo "Blocked: this command touches a secrets file. Ask the user for the value instead." >&2
exit 2
fi
exit 0
Wire it up in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-secrets.sh",
"args": []
}
]
}
]
}
}
Exit code 2 blocks the call and sends stderr back to Claude as the reason, per the hooks docs. The regex is deliberately greedy: it also catches process.env.FOO and .env.example. I'd rather get a false positive than explain a rotated key to my team. It doesn't match .envrc, so add that if you use direnv.
It's only a string match on the command text, though, so grep -r pattern . sails right through it. Anthropic says the same about its own if filters: use the permission system, "rather than a hook to enforce a hard allow or deny". I treat the hook as a readable error message and leave the blocking to the sandbox.
The same fix in Cursor and Codex
Cursor gives you a better default. Its ignore docs say it respects .gitignore plus a built-in list that includes .env*, and you can add paths with .cursorignore. The same page also says: "The terminal and MCP server tools used by Agent cannot block access to code governed by .cursorignore." So you're back to the same Bash hole, without a sandbox config to close it.
Codex handles it as a filesystem permission profile, enforced on sandboxed commands. From the permissions docs, in ~/.codex/config.toml:
default_permissions = "project-edit"
[permissions.project-edit]
extends = ":workspace"
[permissions.project-edit.filesystem.":workspace_roots"]
"**/*.env" = "deny"
Watch for two things. If sandbox_mode shows up in any active config layer, or you pass --sandbox, Codex falls back to the legacy sandbox settings and your profile doesn't apply. And on Linux, WSL and some Windows modes, glob matches are captured "when each sandboxed process starts", so a long-running dev server won't see a .env you create later as denied. For env vars, set shell_environment_policy.ignore_default_excludes = false so Codex strips variables containing KEY, SECRET or TOKEN from subprocesses.
| Tool | Default on .env
|
Built-in file tools | Shell commands |
|---|---|---|---|
| Claude Code | Readable, no prompt | permissions.deny |
Only with sandbox.enabled
|
| Cursor | Ignored (.env* on default list) |
.cursorignore |
Not blocked, per docs |
| Codex | Readable under :workspace
|
Permission profile deny
|
Enforced in sandbox, glob snapshot caveat |
What none of this fixes
The backdoored extension never went through Claude Code's permission system. It ran inside the editor with your user's full access. No deny rule stops that. What helps is a separate user account or container for agent work (a couple of HN commenters suggest exactly this), and not keeping production secrets in plaintext files at all. Inject them at runtime from a vault or op run, and keep .env for local throwaway values.
If you installed 2.1.312 or 2.1.313 in Cursor, run grep -n svc-telemetry ~/.ssh/authorized_keys and check ~/Library/LaunchAgents/com.apple.sysmond.plist on macOS. The label is just a comment and trivial to change, so Phorion also says to check the fingerprint: run ssh-keygen -lf ~/.ssh/authorized_keys and look for SHA256:0Z6R+DF64Do2nwQic8a7kkfDLNNk4IiK+2U2Rci4htk.
My minimum setup: the deny block, the sandbox with failIfUnavailable, and the hook as a tripwire. It takes ten minutes.
So where do your secrets actually live while you code with an agent: .env, a vault, or "I'd rather not say"? And if you think the sandbox is overkill for local dev, tell me why in the comments.
Sources
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.