000040080120160200240
All posts

How to stop your AI coding agent from reading your .env file

An AI coding agent can open any file in your project. That includes your .env, where your API keys, database URLs and service-role tokens live. Here's a small Claude Code hook that says no before the file is ever opened.

01Why one read is enough

The agent doesn't have to be doing anything wrong for this to happen. Ask it "why can't the app connect to the database?" and opening .env to check DATABASE_URL is a perfectly reasonable next step. It's trying to help.

The problem is what happens after. Once the agent reads that file, those values are in its context window: the running conversation the model sees on every turn. From there they can end up in places you never intended.

Diagram: a Read of .env puts the keys into the agent's context window, which then flows into every later model request, session transcripts on disk, and generated code, tool calls or commits
Once a secret is in context, it travels with the conversation. You can't un-read it.
  • Every later request. The context is sent to the model again on each turn, so the key rides along for the rest of the session.
  • Transcripts and logs. Session history is saved to disk in plain text, outside the protections you put on .env itself.
  • Code, tools and commits. The agent might "helpfully" inline a key into a config file, a test fixture, or an argument to another tool.

So the only reliable fix is to stop the read itself. You can do that with a Claude Code hook: a small script that runs before the agent uses a tool, and can say no. The whole setup takes about five minutes.

02What a hook is

Claude Code lets you register shell commands that run at specific points in its lifecycle: when you submit a prompt, before a tool runs, after a tool runs, when Claude finishes, and so on. The one we want is PreToolUse. It fires after the agent decides to call a tool (read a file, edit a file, run a shell command) and before that tool actually runs.

Flow diagram: you ask, Claude plans Read(.env), the PreToolUse hook runs first. Exit 0 allows the tool. Exit 2 blocks it and sends stderr to Claude, which carries on. Any other exit code is a hook error and the tool still runs.
The hook sits between "Claude decided" and "the tool ran". Its exit code is the verdict.

Your script receives a JSON description of the pending tool call on standard input. It then answers with its exit code:

Exit codeWhat happens to the tool callWhere stderr goes
0Runs normallyNowhere that matters
2Blocked. The tool never runs.Back to Claude, as the reason
Anything elseStill runs. It's treated as a hook error, not a "no".Shown to you, not Claude

Two details matter here. First, the agent isn't just stopped. It's told why, so it can pick another approach and carry on with the rest of the task. Second, only exit 2 blocks. If your script crashes or exits 1, the call goes through. Keep that in mind; it comes back in Step 4.

03Step 1: Register the hook

Add this to .claude/settings.json in your project:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Edit|Write|Grep|Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-secrets.sh"
          }
        ]
      }
    ]
  }
}

Here's what each part does:

  • matcher is a pattern over tool names. Read|Edit|Write|Grep|Bash covers every built-in way to touch a file: reading it, changing it, searching inside it, or going around the file tools with cat .env in a shell.
  • command is what runs. $CLAUDE_PROJECT_DIR is set by Claude Code to your project root, so the hook still resolves when Claude has cd'd into a subfolder. The quotes keep it working if the path has spaces.

Where you put this decides who it protects:

FileApplies toShared with the team?
.claude/settings.jsonThis projectYes, commit it
.claude/settings.local.jsonThis projectNo, kept out of git
~/.claude/settings.jsonEvery project on your machineNo, just you

For the user-wide version, keep the script in ~/.claude/hooks/ and point command at ~/.claude/hooks/block-secrets.sh instead, since there's no single project directory to resolve against.

04Step 2: Write the script

Before writing it, look at what it will receive. For a Read call, standard input looks like this:

The JSON a PreToolUse hook receives for a Read call, with tool_name Read and tool_input.file_path pointing at .env highlighted. Annotations: Read, Edit and Write use file_path; Grep uses path and glob; Bash uses command.
Only two fields matter for this hook: tool_name and tool_input. The field inside tool_input depends on the tool.

Now create .claude/hooks/block-secrets.sh. Start with the simplest version that works:

.claude/hooks/block-secrets.sh — simple version
#!/bin/bash
input=$(cat)
target=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.command // ""')

if echo "$target" | grep -E '(^|[ /])\.env' | grep -qv '\.example'; then
  echo "Blocked: .env files are off limits. Use .env.example for variable names." >&2
  exit 2
fi
exit 0

Make it executable, and install jq if you don't have it (it's the tool that parses the JSON):

Terminal
chmod +x .claude/hooks/block-secrets.sh
brew install jq          # macOS
sudo apt install jq      # Debian / Ubuntu

What it does, line by line:

  1. Reads the whole tool-call JSON from stdin into input.
  2. Uses jq to pull out the file path (for Read, Edit, Write) or, if there isn't one, the command (for Bash). The // means "or else", so it falls back to an empty string for anything else.
  3. Checks whether that text contains .env at the start, after a slash, or after a space. That catches .env, config/.env and cat .env, but not src/environments/, because there the dot is missing.
  4. Lets .env.example through, because templates hold variable names, not values, and they're genuinely useful for the agent to read.
  5. On a match, prints a reason to stderr and exits with code 2. That message is exactly what Claude will see, so write it as an instruction: say what to do instead.

05Step 3: Test it without Claude

You don't need Claude running to test a hook. It's just a script that reads stdin, so you can pipe in fake tool calls:

Terminal
echo '{"tool_name":"Read","tool_input":{"file_path":".env"}}' | .claude/hooks/block-secrets.sh
echo "exit code: $?"

Expected: the "Blocked" message and exit code: 2.

I ran the simple version against the obvious cases, and then against a few less obvious ones:

ToolInputSimple version
Read.envBlocked (exit 2)
Read/app/.env.localBlocked (exit 2)
Bashcat .envBlocked (exit 2)
Read.env.exampleAllowed (exit 0)
Readsrc/app.tsAllowed (exit 0)
Bashcat ".env"Allowed: gap
Bashcat .env.example && cat .envAllowed: gap
Grepsearch inside .envAllowed: gap
Any.env, with jq not installedAllowed: gap

The first five rows are what most tutorials stop at. The last four are why it's worth running more than one test:

  • Quotes. In cat ".env" the character before .env is a quote, not a space or slash, so the pattern doesn't match.
  • A template in the same command. The .example check looks at the whole line. If .env.example appears anywhere, the entire command is waved through, including the cat .env after it.
  • Grep. Its target lives in tool_input.path, which the script never reads.
  • Missing jq. The script errors, target ends up empty, nothing matches, and it exits 0. It fails open, silently.

06Step 4: Close the gaps

Here's the version I actually use. It's still about 20 lines:

.claude/hooks/block-secrets.sh — hardened
#!/bin/bash
# Fail closed: without jq we can't inspect the call, so block it.
if ! command -v jq >/dev/null 2>&1; then
  echo "Blocked: jq is not installed, so the secrets hook can't inspect this call." >&2
  exit 2
fi

input=$(cat)
# Collect every field that can name a file: Read/Edit/Write, Grep, Bash.
target=$(printf '%s' "$input" | jq -r '[.tool_input.file_path, .tool_input.path, .tool_input.glob, .tool_input.command] | map(select(. != null)) | join(" ")')

# Remove the safe templates first, then look for any .env that is left.
rest=$(printf '%s' "$target" | sed -E 's/\.env\.(example|sample|template)//g')

if printf '%s' "$rest" | grep -qE '(^|[^A-Za-z0-9_])\.env'; then
  echo "Blocked: .env files are off limits. Use .env.example for variable names." >&2
  exit 2
fi
exit 0

What changed, and why:

  1. It fails closed. If jq is missing, it blocks with a clear reason instead of silently allowing everything. Annoying for a minute, but far better than thinking you're protected when you're not.
  2. It reads every field that can name a file. file_path, path and glob cover Read, Edit, Write and Grep; command covers Bash. Whatever is present gets joined into one string to check.
  3. It removes templates before checking, instead of after. .env.example, .env.sample and .env.template are deleted from the string first. A template can no longer vouch for the rest of the command.
  4. It matches .env after any non-word character. Quotes, =, (, spaces and slashes all count. A letter before the dot doesn't, so process.env and import.meta.env in code or commands are left alone.

Run both versions through the same cases and the gaps close:

ToolInputSimpleHardened
Bashcat ".env"AllowedBlocked
Bashcat .env.example && cat .envAllowedBlocked
Greppath .env or glob .env*AllowedBlocked
Anyjq not installedAllowedBlocked, with reason
Edit/proj/.env.productionBlockedBlocked
Read.env.exampleAllowedAllowed
Bashnode -e "console.log(process.env.HOME)"AllowedAllowed
Bashnpm run devAllowedAllowed

To make re-testing a habit, save the cases as a small script and run it whenever you change the hook:

test-hook.sh
for json in \
  '{"tool_name":"Read","tool_input":{"file_path":".env"}}' \
  '{"tool_name":"Bash","tool_input":{"command":"cat \".env\""}}' \
  '{"tool_name":"Bash","tool_input":{"command":"cat .env.example && cat .env"}}' \
  '{"tool_name":"Grep","tool_input":{"pattern":"KEY","path":".env"}}' \
  '{"tool_name":"Read","tool_input":{"file_path":".env.example"}}' \
  '{"tool_name":"Bash","tool_input":{"command":"npm run dev"}}'
do
  printf '%s' "$json" | .claude/hooks/block-secrets.sh 2>/dev/null
  echo "exit $?  $json"
done

You should see four exit 2 lines followed by two exit 0 lines.

07What it looks like in practice

If Claude Code is already open, start a new session so it picks up the hook (you can also review registered hooks with the /hooks command). Then ask it to "read the .env file".

Illustration of a Claude Code session: Read(.env) is blocked by the PreToolUse hook with the message to use .env.example; Claude explains and reads .env.example instead, then lists the variable names.
Illustration of the exchange. The exact wording in your terminal will vary by Claude Code version.

The call to Read(.env) never runs. Claude sees the hook's message, then moves on. In my case it offered to work from the example config instead, which is exactly what the message told it to do.

Two things that are easy to worry about, but don't need to:

  • Your app still works. The hook only inspects the agent's tool calls. When Claude runs npm run dev, your app loads .env itself through dotenv or your framework, and the command text never mentions the file.
  • Auto-approve doesn't skip it. The hook runs before the permission check, so it applies even to tools you've told Claude Code to run without asking.

Your secrets stay in the file, and out of the context window.

08Limits you should know about

This is a guardrail, not a sandbox. It catches the ordinary, well-meaning ways an agent opens a secret file. It can't stop something that's trying to get around it.

  • It's a text pattern. The hook only sees the command string. A path built indirectly, like cat ./.e* (a glob the shell expands later) or a filename assembled from variables, doesn't contain .env, so it passes. I confirmed the glob case gets through the hardened script too.
  • Programs can still read the file. If Claude runs a script that loads .env and prints its values, the hook sees node print-config.js, not the file. Be wary of debug scripts that dump the environment.
  • It only covers the tools in your matcher. If you add other tools that can read files, such as an MCP filesystem server, add their tool names to the matcher (MCP tools are named like mcp__server__tool, and the matcher accepts patterns such as mcp__filesystem__.*). The hardened script already checks path, which many of them use.
  • Other secret files exist. .env is the common one. To extend the check to *.pem keys, credentials.json and .npmrc, swap the grep line for the one below (and make the message say "secret files").
block-secrets.sh — extended pattern
if printf '%s' "$rest" | grep -qE '(^|[^A-Za-z0-9_])\.env|\.pem($|[^A-Za-z0-9_])|credentials\.json|\.npmrc'; then

09Add a second and third layer

No single control covers everything, so stack a few that fail in different ways:

Table of three layers. Permission deny rules catch built-in file tools by path but miss shell commands. The PreToolUse hook catches file tools and shell command text but misses indirect paths. Keeping real secrets out of local files limits damage when the others miss.
Each layer covers a gap the others leave open.

Layer 1: permission deny rules. Claude Code's own settings can refuse to read specific paths, with no script involved. Add them next to the hooks block:

.claude/settings.json
{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.local)",
      "Read(./.env.production)"
    ]
  }
}

List your real files by name rather than using a wildcard like .env.*, which would also deny .env.example. Deny rules match on paths, so they don't see cat .env inside a shell command. That's the gap the hook fills.

Layer 2: the hook from this post, which also covers Bash and tells Claude what to do instead.

Layer 3: nothing valuable to leak. Keep real production secrets out of local files whenever you can. Use a secrets manager, and short-lived or tightly scoped keys for development. If something does slip through, rotating a scoped dev key is a five-minute job, not an incident.

10Troubleshooting

  • The hook never fires. Check the script is executable (chmod +x), the path in command is right, and settings.json is valid JSON (jq . .claude/settings.json will complain if it isn't). Then start a new session. Running claude --debug shows hook activity as it happens.
  • Every call is blocked with "jq is not installed". That's the fail-closed check working. Install jq and it clears up.
  • A file you need is blocked. The pattern also matches names that start with .env, like .envrc. Add the file to the sed line alongside example|sample|template if it's safe to read, or leave it blocked if it holds secrets too (an .envrc often does).

11Quick checklist

0 / 6 done

12Wrapping up

AI coding agents are most useful when you let them move fast, and hooks let you set the boundaries once instead of watching every step. A 20-line script is a small price for keeping your keys out of the context window. Just remember to test it like an attacker would, not only like a user would. That's where the interesting bugs were.

I share practical dev tips like this regularly. You can follow along on Instagram at @mubashi_mohd.builds or find more at ordinarydev.in.

Written by Mubashir Mohamed · GitHub ↗
More posts