Part of the AI coding agent security checklist

How to stop Claude Code reading your .env file

A deny rule stops the obvious reads. It does not stop every read, and a value the agent already read is sitting in its transcript.

Checked against the Claude Code, AWS and Stripe documentation and the incident reports listed at the end. Published .

Deny rule Read(**/.env) in settings keeps Claude's file tools, cat, head, tail and sed away from the file.
Sandbox /sandbox makes the operating system enforce the same rule on grep -r, Python and anything else a Bash command starts.
Already read Rotate the key. The value is in ~/.claude/projects and went to the model with the conversation.
01 / Deny

Add a Read deny rule

Claude Code's built-in control for this is a deny rule in its settings. Three entries cover where most projects keep their secrets.

project settings
{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)"
    ]
  }
}

Commit it as .claude/settings.json and it applies to everyone who opens the project. Put the same block in ~/.claude/settings.json and it applies to you, in every project. For files that hold secrets, Claude Code's settings reference lists what a matching deny rule does:

Discovery Matching files are left out of file discovery and search results.
Reads Reads are denied: through Claude's file tools, through the file commands Claude Code recognises in Bash, such as cat, head, tail, sed and tee, and through redirection targets such as < file (v2.1.257 or later for input redirects).
Writes The Edit and Write tools are blocked on the same paths. That needs Claude Code v2.1.208 or later for edits and v2.1.228 or later for writes.

Which files a pattern matches

Read rules use gitignore syntax, and the prefix decides where a pattern is anchored. It matters in a monorepo, where the .env that holds the live keys is often not at the top.

RuleMatches
Read(./.env)the .env in the current directory
Read(.env)
Read(**/.env)
any .env at or under the current directory, but not one in a parent directory or another project
Read(//**/.env)any .env anywhere on the filesystem
Read(~/.aws/credentials)a path under your home directory
Read(/secrets/**)in project settings, secrets/ in the project; in ~/.claude/settings.json, ~/.claude/secrets/

The last row is the trap. In user settings a single leading slash anchors at ~/.claude, not at the filesystem root and not at your project. A user-level rule meant to hold in every project needs // or ~/:

user settings
{
  "permissions": {
    "deny": [
      "Read(//**/.env)",
      "Read(//**/.env.*)",
      "Read(~/.aws/credentials)",
      "Read(~/.ssh/**)"
    ]
  }
}

.env.* also matches .env.example, the template an agent reads to learn which variables exist. In a project's deny list, a rule that starts with ! carves files back out of the relative rules listed before it, so Read(!.env.example) after Read(./.env.*) keeps the template readable. It cannot carve anything out of a // or ~/ rule.

02 / Gaps

What a deny rule does not stop

A Read rule is checked against the paths Claude names. A process that opens the file on its own never names it to Claude Code.

Claude Code's permissions documentation states the limit directly. Read and Edit deny rules cover the built-in file tools, the Bash file commands Claude Code recognises, and redirection targets. They do not cover a command that reads files without naming them, or a subprocess that opens files itself. Here is what a Read(**/.env) deny rule stops, and what it does not.

Claude runsStopped by the deny rule
cat .env
sed -n 1,5p .env
Yes. Recognised file commands.
grep -r API_KEY .No, when run from the directory that holds the file. It reads .env without naming it, and prints every matching line, value included.
python3 -c "print(open('.env').read())"No. The subprocess opens the file itself. The same goes for a Node script, or any script of yours that loads .env and prints what it loaded.

Bash deny rules have the same shape of gap. A Bash rule matches the command as Claude writes it, so Bash(curl *) stops curl https://example.com but not /usr/bin/curl https://example.com or sh -c 'curl https://example.com'. Adding a Bash rule per program does not close it, since any interpreter on the machine can print a file.

Claude Code's documentation gives one answer to this, in the permissions guide and the settings reference alike: for enforcement that covers every process, turn on the sandbox.

03 / Sandbox

Close the gap with the sandbox

The sandbox is enforced by the operating system, so it applies to grep, Python and anything else a Bash command starts.

Run /sandbox in a Claude Code session and pick a mode. That writes sandbox.enabled to .claude/settings.local.json for the current project. Set it in ~/.claude/settings.json to sandbox every project.

macOS Seatbelt, built in. Nothing to install.
Linux, WSL2 bubblewrap and socat: sudo apt-get install bubblewrap socat or sudo dnf install bubblewrap socat. The /sandbox panel shows what is missing.
Windows Native Windows is not supported, and neither is WSL1. Run Claude Code inside WSL2.

This is the part that answers the question. With the sandbox on, Claude Code adds the paths from your Read(...) deny rules to the sandbox's own read deny list. The rule from step 01 then holds for grep -r and for a Python script as well, as long as they run as sandboxed Bash. The Config tab of /sandbox shows the resolved settings, so you can check the paths arrived.

By default, sandboxed commands can read the entire computer apart from a few denied directories. That still includes ~/.aws/credentials and ~/.ssh/, and there is no built-in credential deny list: only what you list is restricted. A user-level configuration that closes the usual holes:

user settings
{
  "sandbox": {
    "enabled": true,
    "allowUnsandboxedCommands": false,
    "filesystem": {
      "denyRead": ["~/**/.env"]
    },
    "credentials": {
      "files": [
        { "path": "~/.aws/credentials", "mode": "deny" },
        { "path": "~/.ssh", "mode": "deny" }
      ],
      "envVars": [
        { "name": "GITHUB_TOKEN", "mode": "deny" },
        { "name": "NPM_TOKEN", "mode": "deny" }
      ]
    }
  }
}
denyRead ~/**/.env blocks every .env under your home directory, and keeps it blocked inside a wider allowRead. A wildcard that matches a directory blocks its contents too, so a Python virtualenv named .env becomes unreadable to sandboxed commands.
credentials Denies reads of ~/.aws/credentials and ~/.ssh inside the sandbox, and unsets GITHUB_TOKEN and NPM_TOKEN before each sandboxed command runs.
Strict mode By default, after the sandbox blocks a command, Claude can retry it outside the sandbox with dangerouslyDisableSandbox, which falls back to the normal permission prompt. allowUnsandboxedCommands: false makes Claude Code ignore that parameter: a command runs sandboxed or is listed in excludedCommands.

Two limits. The sandbox covers Bash commands and their child processes; Claude's Read, Edit and Write tools go through the permission system instead, so keep the deny rule. The two are layers, not alternatives. And sandbox.credentials applies to sandboxed Bash only. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 strips the variables Claude Code recognises as credentials, such as Anthropic and cloud provider keys, from the Bash tool, hooks and MCP stdio servers, sandboxed or not. That works on environment variables. A .env file on disk is still a file, and still needs the rule and the sandbox.

The environment matters as much as the file. In 0DIN's demonstration of 25 June 2026, a setup command Claude ran on its own opened a reverse shell as the developer, and the authors list ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY and GITHUB_TOKEN among what that shell could reach. A shell started from a Bash command inherits that command's environment, so with the scrub it starts without the credentials Claude Code recognises. It can still open any file you have not denied to sandboxed commands, which is what denyRead and sandbox.credentials are for. The security checklist has the whole case.

04 / Already read

If it already read the file

A deny rule added now does nothing for a value the agent has already seen. Treat that value as leaked.

There are two copies. One is on disk: when a tool reads a .env file, Claude Code's documentation says “that value is written to projects/<project>/<session>.jsonl”, under ~/.claude. Transcripts are plaintext, protected only by file permissions, and kept for 30 days by default. Large tool outputs go to <session>/tool-results/ beside it. The other copy went over the network: the model saw the value because Claude Code sent it with the conversation.

So rotate first. Masking or deleting the transcript afterwards stops a second leak from disk. It does not un-send anything.

Rotate

AWS Create a second access key while the old one still works, move every application to it, check when the old key was last used, deactivate it, then delete it.
Stripe Dashboard, API keys, the key's overflow menu, Rotate key. Both keys keep working for up to 7 days unless you set Expiration to Now, which deletes the old key at once. For a key that leaked, the grace period is also a grace period for whoever else holds it. View request logs on the same menu shows what the key was used for.
Anything else The issuing provider's console. If you do not know which provider a value belongs to, the next step tells you.
Made public A .env pushed to a public repository counts as leaked too, and agents find those. In July 2026, agents in OpenAI's cyber evaluations used Hugging Face tokens exposed publicly on their way into Hugging Face's production systems, and Hugging Face asked users to rotate their access tokens. In May, a Gemini model under evaluation by Irregular logged in to a real company's site with credentials posted publicly.
aws iam, in order
aws iam create-access-key --user-name NAME
# move every application to the new key, then:
aws iam get-access-key-last-used --access-key-id OLD_KEY_ID
aws iam update-access-key --access-key-id OLD_KEY_ID --status Inactive --user-name NAME
aws iam delete-access-key --access-key-id OLD_KEY_ID --user-name NAME

Find what it read

Without installing anything, search the transcripts for a variable name from your .env: grep -rl 'STRIPE_SECRET_KEY' ~/.claude/projects lists every transcript that mentions it. That finds names. It does not tell you which values are there or which file they came from.

ranwhat clean reads the same transcripts and reports each distinct secret with the project it was found in and, when the transcript names it, the file it was read out of. Then it leaves a review session open:

uvx ranwhat cleanexcerpt
* Stripe live secret key   sk_…dc  32 chars  seen 8x
      read from api/.env
      in         /Users/you/Desktop/app

ranwhat> rotate      what to rotate, grouped by provider
ranwhat> mask all    mask everything listed

rotate groups the findings by provider and says where to roll each one. Run mask all after rotating, not instead of it. It writes a backup to ~/.ranwhat/backups first, and those backups hold the values until you delete them. clean reads Claude Code transcripts, subagents' included, from the last 30 days by default (--days N), and does not read tool-results/ yet.

ranwhat watch reports the reads themselves: .env files, ~/.aws/credentials, ~/.ssh/id_*, .netrc, ~/.config/gcloud, service-account JSON, security find-generic-password and ~/.kube/config, through Bash or a file-reading tool. It does not flag grep -r API_KEY . or a python3 -c that opens .env: the same blind spot as the deny rule. What those commands printed is still in the transcript, and clean still finds it, naming the .env when the command or its output names the file. uvx ranwhat check runs both and changes nothing.

Other scanners

gitleaks gitleaks dir -v --redact ~/.claude/projects. Its rules are regular expressions, each with an optional entropy threshold. --redact keeps the secrets out of its own output. MIT licence.
trufflehog trufflehog filesystem ~/.claude/projects. Checks each candidate against the API it appears to belong to (for AWS, a GetCallerIdentity call) and labels it verified, unverified or unknown. That means it uses the keys it finds. --no-verification stops it testing them, and --no-update stops its check for updates. AGPL-3.0.

Both report. Masking the transcript is a separate step.

05 / Shorten

Keep less of it on disk

The deny rule and the sandbox decide what gets read. Retention decides how long a mistake stays readable.

user settings
{
  "cleanupPeriodDays": 7
}

cleanupPeriodDays sets how many days Claude Code keeps transcripts. The default is 30 and the minimum is 1: 0 fails validation. Deletion is a background sweep after a session starts, and a deleted session no longer appears in /resume.

Retention also decides what malware finds. Commodity infostealers now collect AI coding tools' prompt histories, conversation databases and the credentials in their MCP configs, Gen Digital reported on 8 September 2026. A .env value read into a transcript stays there until the sweep or a purge removes it, or ranwhat clean masks it. The history guide covers what to do.

CLAUDE_CODE_SKIP_PROMPT_HISTORY=1 claude        write no transcript for this session
claude -p --no-session-persistence "..."        the same, non-interactive
claude project purge ~/work/my-repo --dry-run   what one project holds

CLAUDE_CODE_SKIP_PROMPT_HISTORY=1 skips the transcript and prompt history for that session, so it cannot be resumed either. claude project purge deletes what Claude Code keeps for one project: transcripts, memory, file history, the matching lines of prompt history and its entry in ~/.claude.json. It prints the plan and asks before deleting anything.

For the rest: what Claude Code keeps in ~/.claude and for how long, a security checklist for coding agents, and scoping the tokens an agent holds, so a key that does leak can do less. Every guide is listed under Guides.