Stop Claude Code Reading Your .env: deny vs hook vs sandbox
TL;DR To keep Claude Code out of your .env, permissions.deny (Read(./.env)) stops the honest mistakes — the Read tool and a literal cat .env — but it is a string match, so node -e readFileSync('.env') or a filename assembled from fragments reads right through. A PreToolUse hook catches the script but still falls to the assembled name. Only the sandbox (sandbox.enabled with sandbox.filesystem.denyRead, Claude Code v2.1.187+) blocks every bypass at the OS level with EPERM. Ranking: sandbox > hook > deny. deny is a mistake-guard, not a security boundary.
My own .env.local had no protection at all. I found that out while trying to answer someone else’s question about how to lock theirs down — and realized I’d never actually tested whether the usual advice for protecting .env (or .env.local) from Claude Code actually works.
The usual advice is permissions.deny. Add Read(./.env) to your settings.json and Claude Code won’t read your secrets. A web search I ran while drafting a reply went further: it told me permissions.deny only blocks the Read tool, that cat .env is a different path that slips past it, and that a PreToolUse hook is “the only fully reliable protection.” I almost pasted that into a public answer.
Then I tested all three on this machine (Claude Code 2.1.207, macOS). Every result in this article comes from the same test environment shown below, and I reverted every setting afterward. Both of those claims turned out wrong. deny does block cat .env. And a hook is not reliable — it falls to the same trick deny does. The only defense that actually held was the sandbox.
Here’s the ranking up front: sandbox > hook > deny. deny is a mistake-guard, not a security boundary. This post is the measurement behind that, and a checker at the end so you can see where your own setup lands. It’s a direct sequel to my post on Claude Code MCP permissions — that one was about what the agent can reach; this one is about what happens when you try to slam one specific door.
The three defenses, in one line each
Before the numbers, what each thing actually is. This matters, because the reason two of them fail is baked into what they are.
permissions.deny— a string match on the tool call or the Bash command. You writeRead(./.env)andRead(./.env.*)insettings.json, and Claude Code refuses any call where that path appears. Its whole world is the literal text of the request.- PreToolUse hook — a script that intercepts the Bash command string before it runs and can reject it. Still string matching, but it sees more strings: it can grep the full command, so it catches a Node one-liner that a plain
denymisses. - sandbox —
sandbox.enabledinsettings.json, withsandbox.filesystem.denyReadnaming the protected paths. This is OS/kernel-level. The block is enforced by the sandbox when the file is opened, not by inspecting the command text. Requires Claude Code v2.1.187+. (There’s alsosandbox.credentials.filesfor named credential files, added later in v2.1.191+; I tested withfilesystem.denyRead.)
Two of these read a string and decide. One of them guards the file itself. Keep that distinction in mind — it’s the whole result.
Tested environment
- Claude Code: 2.1.207
- OS: macOS
- Sandbox: enabled (
sandbox.enabled, withsandbox.filesystem.denyRead) - Test file: a dummy
.envwith a fake secret — never my real.env.local - Date: July 2026
What I measured, and against what
I made a dummy file named .env with a fake secret in it (never my real .env.local). Then I tried to get the agent to read it five different ways, under each defense. These are the exact commands, from honest to adversarial:
- Read tool — the agent’s normal file read.
cat .env— the literal filename in a Bash command.node -e "require('fs').readFileSync('.env')"— a script that opens the file, literal name.- assembled filename (runtime filename construction with shell variables) — building the path from fragments so
.envnever appears as one string:A=".e"; B="nv"; cat "${A}${B}" - node + assembled — the script and the assembled name together. The strongest.
The point of #4 and #5 isn’t that an attacker types them. It’s that a string-matching defense can’t tell the difference between “the agent innocently reads a file” and “a filename got built out of pieces.” If the literal .env never shows up in the text, a string match has nothing to match.
The results
The table below summarizes every read attempt — blocked or successful — against all three defenses under identical conditions.
| Bypass | permissions.deny | PreToolUse hook | sandbox |
|---|---|---|---|
| Read tool | blocked | blocked | blocked |
cat <file> (literal) | blocked | blocked | blocked |
node -e readFileSync(<literal>) | read | blocked | blocked |
assembled filename (cat "${A}${B}") | read | read | blocked |
| node + assembled (strongest) | read | read | blocked (EPERM) |
Read the columns top to bottom. deny holds for the two honest cases and breaks the moment a script opens the file. The hook survives the script — it can see readFileSync('.env') in the command string — but breaks on the assembled name. The sandbox blocks all five. Every adversarial read against it came back EPERM: operation not permitted.
One correction to that web search I almost trusted: it claimed cat .env slips past deny. In this version it doesn’t — deny blocks the literal Bash path fine. What leaks past deny is the script and the assembled path, not cat. Getting that detail wrong in a public reply is exactly the kind of thing this blog exists to avoid. A wrong fact is the most expensive thing you can publish.
Why deny and hook leak
Both deny and the hook are looking at text. That’s the entire story.
deny is a string match on the request, so a script reads through it. It matches the tool call and the command string. The Read tool is blocked (the path is right there in the call), and cat .env is blocked (the path is right there in the command). But node -e "require('fs').readFileSync('.env')" hands the file operation to Node — from deny’s point of view the risky thing is inside a script it doesn’t parse, and it lets the command run. Conclusion: deny catches literal paths, nothing more.
The hook greps the whole command, so it catches the script — but not an assembled name. It can see .env sitting inside the Node one-liner, so it rejects that. But grep only finds what’s literally there. Assemble the name from fragments and the literal .env never appears in the command:
A=".e"; B="nv"; cat "${A}${B}"
The hook’s grep sees A, B, and cat "${A}${B}". It does not see .env, because .env doesn’t exist until the shell expands the variables — after the hook has already decided. Bypassed. Conclusion: the hook raises the bar over deny but is still string matching, so “a hook is the only fully reliable protection” is false.
The sandbox is different in kind, not degree
The sandbox never reads your command. It doesn’t care how you spell the filename or whether you use a script. When the process actually calls open() on a denied path, the kernel refuses. EPERM. It doesn’t matter that the name was assembled at runtime, because by the time the name exists, the open is already happening — and that’s the thing being blocked, not the text that led to it.
I confirmed it’s targeted, not a blanket lockout: with the sandbox on and .env denied, package.json still read fine. It blocks the paths you name and nothing else.
That’s the difference between a string match and a boundary. A string match asks “does this request look dangerous?” A boundary asks “is this file allowed to be opened?” — and only the second question survives an adversary who controls how the request is phrased.
The real ranking, and when to use what
sandbox > hook > deny. But “use the strongest” isn’t quite the advice, because they solve slightly different problems.
- Just want the agent to not stumble into
.envby accident?permissions.denyis enough. Most of the time the agent isn’t adversarial — it’s an honest tool that mightcatthe wrong file.denystops that, and it’s one line. Keep it. Just don’t mistake it for security. - Want it actually prevented — even under a script, or an injected instruction that’s trying to exfiltrate? Use the sandbox (
sandbox.filesystem.denyRead, v2.1.187+). It’s the only option that holds when something is deliberately working around it. - Can’t use the sandbox (older version, environment that won’t allow it)? A hook sits in between and is worth it as a fallback. Just know its ceiling.
The prompt-injection case is why the distinction isn’t academic. If a document the agent reads contains an instruction to exfiltrate .env, deny and the hook are exactly the layers a cleverly-phrased instruction can route around — assemble the name, use a script. The sandbox is the layer that doesn’t care what the instruction said.
Check your own setup
I’ll be honest about where this started: my real .env.local had none of these. If yours is the same, this is the two-minute fix. Answer three questions and you’ll see where you land and what to do next.
Is your .env actually protected from Claude Code?
Three questions about the defenses you’ve set up. Nothing leaves your browser.
Tap yes/no on each to see where your setup lands.
One sentence to keep: a defense that reads your command is a mistake-guard; a defense that guards the file is a boundary. deny and hooks read the command. The sandbox guards the file. If your secrets actually matter, put the boundary in.
Put another way: if your threat model includes prompt injection or malicious instructions, only the sandbox is a real security boundary — a string match is not.
Tested on Claude Code 2.1.207, macOS. Sandbox requires v2.1.187+. All test config and the dummy file were reverted after the run. Drafted with AI assistance, measured and reviewed by me before publishing.