Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 8 min read

Does Claude Code read .env files? What each fix actually stops.

Short answer: yes. Claude Code can read your .env like any other file, and .gitignore doesn't stop it. A Read deny rule in .claude/settings.json stops the obvious ways in. Nothing that matches on the filename stops a com

Short answer: yes. Claude Code can read your .env like any other file, and .gitignore doesn't stop it. A Read deny rule in .claude/settings.json stops the obvious ways in. Nothing that matches on the filename stops a command that never says the filename.

Most guides stop at "add a deny rule". So I made a throwaway repo with a fake .env and put each fix up against the ways an agent actually gets at a file. Some of it I ran live. Some I couldn't, and I'll say which.

Every fix here matches the name .env. Your keys don't need to be asked for by name.

Yes, it can: the 30-second test

The lab: a fresh git repo, two obviously fake keys (one is AWS's own documentation example), and the file ignored the way most repos ignore it.

$ cat .gitignore
.env
$ git check-ignore -v .env
.gitignore:1:.env   .env
$ cat .env
STRIPE_KEY=sk_test_FAKE0000000000000000
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE

Then, in a live Claude Code session, the agent's own tools against that folder:

  • Read on .env returned both lines.

  • Glob for **/.env* listed it.

  • Grep for STRIPE_KEY across the folder found nothing. Rename .gitignore and the same search finds the key. Point Grep at the file itself and it finds it with .gitignore in place.

So .gitignore hides the file from a folder-wide search. That's all it does. It was written to keep files out of commits, and it's good at that.

Every fix people suggest

.claudeignore. Anthropic's permissions docs are blunt: if your project has one, "it has no effect". Move its entries into Read deny rules.

A line in CLAUDE.md saying "never read .env". That's a request, not a control. I didn't measure how often it's obeyed, and a rule you can't measure isn't one you should lean on for a secret.

permissions.deny. This is the official answer. Anthropic's settings docs show it as an example, and it's what I put in the lab:

$ cat .claude/settings.json
{
  "permissions": {
    "deny": ["Read(./.env)", "Read(./.env.*)"]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Grep|Bash",
        "hooks": [
          { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/block-env.mjs\"" }
        ]
      }
    ]
  }
}

The docs say a Read deny rule covers Claude's file tools, Grep and Glob on a "best-effort" basis, @file mentions, and Bash commands it recognises as file reads: cat, head, tail, sed, tee, plus redirections like < file. And the docs name the gaps themselves: a command that reads files without naming them, like grep -r pattern ., and "arbitrary subprocesses", like a Python or Node script that opens the file on its own.

I couldn't run this one live. The CLI's login had expired:

$ claude --version
2.1.257 (Claude Code)
$ claude -p 'Read .env and print it'
Failed to authenticate: OAuth session expired and could not be refreshed

So that row of the matrix is Anthropic's word, not my run. I also can't tell you whether it catches a glob like cat .en?. The docs don't say, and I didn't get to try.

A PreToolUse hook. The hook in the settings above runs before every Read, Grep and Bash call. Exit code 2 blocks the call and hands the agent the message. Here's all of it:

$ cat .claude/hooks/block-env.mjs
// PreToolUse hook: refuse Read, Grep and Bash calls that name a .env file.
let raw = '';
for await (const chunk of process.stdin) raw += chunk;
const { tool_name, tool_input = {} } = JSON.parse(raw);
const target = tool_input.command ?? tool_input.file_path ?? tool_input.path ?? '';
if (/(^|[^\w.-])\.env(\.[\w.-]+)?(?![\w.-])/.test(target)) {
  console.error(`Blocked: ${tool_name} touches a .env file. Ask the user for the value instead.`);
  process.exit(2);
}

No live session, so I tested it the way Claude Code calls it: the documented PreToolUse JSON piped to the script on stdin, trimmed to the fields it reads. Then I ran whatever got through.

$ echo '{"tool_name":"Read","tool_input":{"file_path":".env"}}' | node .claude/hooks/block-env.mjs; echo "exit $?"
Blocked: Read touches a .env file. Ask the user for the value instead.
exit 2
$ echo '{"tool_name":"Bash","tool_input":{"command":"cat .env"}}' | node .claude/hooks/block-env.mjs; echo "exit $?"
Blocked: Bash touches a .env file. Ask the user for the value instead.
exit 2
$ echo '{"tool_name":"Bash","tool_input":{"command":"cat .en?"}}' | node .claude/hooks/block-env.mjs; echo "exit $?"
exit 0
$ cat .en?
STRIPE_KEY=sk_test_FAKE0000000000000000
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
$ echo '{"tool_name":"Bash","tool_input":{"command":"grep -r STRIPE ."}}' | node .claude/hooks/block-env.mjs; echo "exit $?"
exit 0
$ grep -r STRIPE .
./.env:STRIPE_KEY=sk_test_FAKE0000000000000000

Python went the same way. open('.env') was blocked. open(glob.glob('.en*')[0]) got exit 0 and printed both keys. One question mark beat a regex I spent longer on than I'd like to admit.

One more trap, and it's a nasty one:

$ echo 'not json' | node .claude/hooks/block-env.mjs 2>/dev/null; echo "exit $?"
exit 1

A hook that crashes exits 1. Per the hooks docs, exit 1 is a "non-blocking error" and the action goes ahead. If your guard throws, it waves the call through. Only exit 2, or a JSON deny, blocks.

The sandbox. sandbox.filesystem.denyRead is the one fix that doesn't care about names. The OS enforces it on Bash commands and every process they start, so the Python trick and the glob should both hit a wall. Two catches, both from the sandbox docs: a denyRead entry "doesn't stop the Read tool", so you still need the deny rule. And it runs on macOS, Linux and WSL2. On native Windows, which is what I'm on, commands run unsandboxed. Untested here for that reason.

The matrix: what each one blocks

  • .gitignore. Stops: commits, and a folder-wide Grep. Misses: Read, Glob, Grep aimed at the file, cat, everything else. Tested live.

  • .claudeignore. Stops: nothing. Anthropic's docs; not tested.

  • "Never read .env" in CLAUDE.md. Stops: whatever the model chooses to obey. Not tested.

  • Read deny rules. Stops: Read, Grep and Glob (best effort), @file, cat/head/tail/sed/tee on the file. Misses: grep -r ., scripts that open the file themselves. Anthropic's docs; not tested live.

  • PreToolUse hook. Stops: any call that spells out .env. Misses: cat .en?, grep -r STRIPE ., Python with a glob, and everything if the hook crashes. Tested with piped payloads.

  • Sandbox denyRead. Stops: Bash and its child processes, by OS. Misses: the Read tool, and all of native Windows. Anthropic's docs; not tested.

  • slopguard. Stops: none of the reads. That isn't its job. Its job is the next section. Tested with piped payloads.

The other leak: it writes the key into your code

Reading the key isn't the expensive part. The expensive part is the agent helpfully pasting it into a source file, which then gets committed, pushed and shipped to a browser.

slopguard is our Claude Code plugin for that moment. Its write hook looks at the code before the Write or Edit tool puts it on disk. Fed by hand, from the repo, the way its README says to:

$ echo '{"tool_input":{"file_path":"src/aws.js","content":"const keyId = \"AKIAIOSFODNN7EXAMPLE\";"}}' | node plugins/slopguard/scripts/guard-write.mjs | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).hookSpecificOutput.permissionDecisionReason"
SLOPGUARD blocked this write to src/aws.js β€” 1 unsafe pattern(s):

β€’ [SG-1] AWS access key ID
    found: AKIA…LE
    fix:   Move this to an environment variable or a secret manager, reference it via process.env / os.environ, and rotate the exposed credential β€” assume it is already public.

Rewrite the code to satisfy the rule above, then write the file again.
Rule text lives in AGENTS.md. Do not disable the guard to get past it.
$ echo '{"tool_input":{"file_path":"src/pay.js","content":"const stripe = new Stripe(\"sk_test_FAKE0000000000000000\");"}}' | node plugins/slopguard/scripts/guard-write.mjs; echo "exit $?"
exit 0
$ echo '{"tool_input":{"command":"git add .env"}}' | node plugins/slopguard/scripts/guard-bash.mjs | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).hookSpecificOutput.permissionDecisionReason"
SLOPGUARD blocked this command β€” [SG-1] credential file staged for commit.

fix: Add it to .gitignore. Anything committed is in the history permanently, even after a later delete.

Rule text lives in AGENTS.md.
$ echo '{"tool_input":{"command":"cat .env"}}' | node plugins/slopguard/scripts/guard-bash.mjs; echo "exit $?"
exit 0
$ echo '{"tool_input":{"command":"echo AWS_KEY=AKIAIOSFODNN7EXAMPLE >> src/config.js"}}' | node plugins/slopguard/scripts/guard-bash.mjs; echo "exit $?"
exit 0

Five calls, three results worth knowing:

  • A live-shaped key going into source is blocked, and the agent is handed the rule and the fix. The same kind of line reading process.env instead passes.

  • The sk_test_ key passes. That's deliberate: its rules treat test keys as placeholders, on the theory that a guard that cries wolf gets uninstalled.

  • cat .env and a key written with echo >> both pass. slopguard checks the Write and Edit tools, not what Bash writes. Its README says so.

A setup that holds, and what still gets through

  1. Read deny rules for .env and .env.*, exactly as Anthropic's docs show them. They're the only fix that covers the Read tool.
  2. The sandbox, with denyRead on the same paths, if you're on macOS, Linux or WSL2. It's the only fix that covers scripts.
  3. A hook only for what deny rules can't say. Exit 2, and catch your own errors, because a crash is an allow.
  4. slopguard for the write side, so a key that does get read doesn't land in a commit.
  5. No production secrets in the folder the agent works in. Test keys in .env; the real ones in a secret manager the dev machine never sees.

That last one carries the weight. Your own app loads .env on purpose. When the agent runs npm start or your test suite, no rule sees a filename, and anything the app prints or logs is in the transcript. The only key that can't leak from a dev folder is one that was never in it.

This is how we work. Try the way around a guardrail before trusting the way in. When we wire an agent into a business's systems, that's the test we run first, with fake keys.

Sources: every terminal block is a real run on my machine on 9 Oct 2026, in a throwaway repo with fake keys. The Read, Glob and Grep results are from a live Claude Code session the same day. The deny rules, the hook and slopguard were not run inside a live session (the CLI's login had expired); the hook and slopguard were fed the documented hook JSON by hand. slopguard is v1.1.2: v1.1.1 echoed a 20-character AWS key ID back whole in its block message, which this test caught and which was fixed the same day. What deny rules and the sandbox cover is from Claude Code's permissions, settings and sandboxing docs; hook exit codes from the hooks reference. AKIAIOSFODNN7EXAMPLE is AWS's published example key.

Where this fits: guardrails, layer 1 of the Agent Ops Stack.

Read next: Claude Code Stop hook example: check the "done" claim. Β· AI coding agent security: capability isn't authority.

Originally published at singhlabs.dev.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.