Claude Code and .env files — the certification answer that isn't enough

Claude Code and .env files — the certification answer that isn't enough

The certification question has one correct answer out of four — a PreToolUse hook on Read and Grep. In production, your `.env` still leaks through Bash. A working hook that closes it.

Jakub Kontra
Jakub Kontra
Developer

The certification question has a single correct answer out of four. Even if you nail it, your .env is still readable.

The certification question and the quick answer

The quiz prompt goes roughly like this: "How do you stop Claude Code from reading the contents of .env files?" The choices:

  • A) PostToolUse hook, matching Write and Edit.
  • B) PreToolUse hook, matching Write and Create.
  • C) PreToolUse hook, matching Read and Grep.
  • D) PostToolUse hook, matching Read and Delete.

The answer is C — the only PreToolUse hook that targets read tools. In production, though, the Read|Grep matcher alone won't shut your .env down.

Why A, B, D fail

A is PostToolUse. The tool already ran, the contents of .env are in the tool result, and the model has seen them. Blocking Write and Edit also protects against writing, not reading. Two errors in one.

B sounds fine until you remember that the Create tool doesn't exist in Claude Code. New files are created via Write. And Write doesn't address reading anyway.

D has both errors. Delete doesn't exist (you delete via Bash rm) and PostToolUse comes too late. Incidentally, the quiz tests whether you know the actual tool inventory: Read, Write, Edit, NotebookEdit, Glob, Grep, Bash, Task, WebFetch, WebSearch, TodoWrite, BashOutput, KillShell, SlashCommand, ExitPlanMode. That's the base inventory. MCP servers extend the list dynamically, so check your matcher against what actually runs in the project.

A PreToolUse hook in one minute

PreToolUse runs before the tool executes. The hook script gets a JSON payload on stdin with session_id, transcript_path, cwd, hook_event_name, tool_name, and tool_input. Exit 0 lets the tool through, 2 blocks it and stderr goes back to the model as feedback. Anything else is a hook error and the tool runs anyway.

The matcher in settings.json is a regex over tool_name. "Read|Grep|Glob" catches all three with one config. I include Glob deliberately: a bare Glob .env* doesn't read the contents, but it tells the model the file exists. That's enough for the model to attempt the read another way. For the details on all nine events (PreToolUse, PostToolUse, UserPromptSubmit, Notification, Stop, SubagentStop, PreCompact, SessionStart, SessionEnd), see the complete hooks guide.

A working hook for Read, Grep, and Glob

You drop the hook into .claude/settings.json in the project (committable) or into ~/.claude/settings.json (user-level):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Grep|Glob",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-env.sh" }
        ]
      }
    ]
  }
}

And the .claude/hooks/block-env.sh script:

#!/usr/bin/env bash
set -euo pipefail

input=$(cat)
path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty')

[ -z "$path" ] && exit 0

resolved=$(realpath -m "$path" 2>/dev/null || echo "$path")
base=$(basename "$resolved")

case "$base" in
  .env|.env.*)
    echo "Blocked: $resolved matches .env policy. Use a vault or ask the user." >&2
    exit 2
    ;;
esac

exit 0

Where the hook will trip you up: Read uses file_path, Grep and Glob use path (and pattern), so parse both. Without realpath, ln -s .env public.txt walks right past you — symlinks are a classic bypass. And match on basename, not the full path, otherwise apps/api/.env or packages/db/.env.production will leak in a monorepo.

The hole the quiz doesn't mention: Bash

The Read|Grep|Glob matcher catches none of this:

cat .env
source .env
grep SECRET .env
export $(cat .env | xargs)
tail -f .env.production
awk '{print}' .env

Each of those commands runs through the Bash tool, not Read. The model will use them if the user says "load the variables" and nobody explained that it can't. The second matcher branch is mandatory:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Grep|Glob",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-env.sh" }
        ]
      },
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-env-bash.sh" }
        ]
      }
    ]
  }
}

The Bash variant reads tool_input.command and runs a regex:

#!/usr/bin/env bash
set -euo pipefail

cmd=$(jq -r '.tool_input.command // empty')

if echo "$cmd" | grep -Eq '(\.env($|\.[a-zA-Z0-9_.-]+)|/\.env($|\.))'; then
  if echo "$cmd" | grep -Eq '(cat|less|more|head|tail|grep|awk|sed|source|\.\s|xxd|od|base64|export)\b'; then
    echo "Blocked: Bash command reads .env. Reject or refactor." >&2
    exit 2
  fi
fi

exit 0

Expect the regex to occasionally trip on an innocent command — a comment, a log line, or an import path that just happens to contain .env. For that case, keep an open bypass via .claude/settings.local.json or simply rephrase the command. The bar moves from "one line" to "you have to mean it".

What even two branches can't catch

A regex over command loses to interpreters:

node -e "console.log(require('fs').readFileSync('.env','utf8'))"
python -c "print(open('.env').read())"
ruby -e "puts File.read('.env')"
perl -e 'print <>' < .env

You can add regexes for node -e, python -c, ruby -e, perl -e, but it's a race you won't win. The safety nets that make sense alongside the hook:

  • permissions.deny in settings.json — a declarative layer, current syntax Read(./.env), Read(./.env.*), Bash(cat:*). Less flexible than a hook, but readable in review.
  • Filesystem permissions — .env owned by another user, the Claude process has no read bit.
  • Secret rotation and a vault (1Password CLI, doppler, sops) — .env simply isn't in the repo.
  • .gitignore does not protect access — Claude Code doesn't read it. To block reads, use permissions.deny or hooks.

The certificate gives you a point for C. Your .env is only protected by a hook that knows about Bash, symlinks, and a nested .env.production. Even then, you're left with one interpreter you have to trust.

The full hooks map — nine events, exit codes, JSON I/O, security pitfalls — is in the Claude Code hooks guide.

I run workshops and code reviews of hooks/permissions setups for engineering teams. Email me@jakubkontra.com.