Stop Leaking Secrets to Claude Code A Practical Security Setup

What actually blocks secret leaks, what looks like protection but is not, and the leak paths most write-ups skip - all sourced against Anthropic's official docs.

April 12, 2026 14 min read
Threat Model Why CLAUDE.md Is Not Enough The Fix: Deny Rules What Deny Rules Block Verify Your Rules Closing the Bash Hole Blocking Runtime Leaks Pre-Commit Hooks Managed Settings A Starting Config The Checklist

Most developers think their .env file is safe because it is in .gitignore. Then they hand a coding agent the keys to their project and assume the same logic applies. It does not. "Ignored by git" and "ignored by Claude Code" are two different things, governed by different config files.

Claude Code does not auto-scan and slurp up your .env on startup. But it will read whatever it judges relevant to the current task, and once a secret enters a tool result, it is part of the conversation context. From there, depending on your privacy settings and plan, it may end up in logs sent back to Anthropic.

The fix is mostly one config file, one habit, and - for high-stakes work - one OS-level lock. This post walks through what actually works according to Anthropic's official docs, what looks like protection but is leakier than it seems, and the leak paths most articles skip. Every claim below is sourced. References are at the bottom.

Claude Code permissions deny configuration blocking .env access

The Threat Model: Three Ways Secrets Actually Leak

Before configuring anything, get the threat model right. Secrets reach the conversation through three distinct paths, and each needs a different defense.

Three Leak Paths Into Conversation Context
.env / *.pem / *.key Direct file reads App stdout / logs Runtime output capture grep / find results Search and surrounding lines Read tool Edit / Glob Bash tool cat / curl / pytest Grep tool ripgrep wrapper Conversation Context May reach Anthropic logs 1 2 3
  1. Direct file reads. You ask Claude to refactor your config loader. It opens .env to understand the variable names, and the values come along for the ride. This is the textbook leak and the easiest one to block.
  2. Runtime output capture. Claude runs your test suite or boots your dev server. A failing request logs the full Authorization: Bearer sk-live-... header. A database error dumps the connection string with the password. The .env file was never opened, but the secrets are now in context anyway, courtesy of your own application's logs.
  3. Search and grep results. Claude runs grep for a function name. The match happens to be next to a hardcoded API key in a forgotten config file. The grep output includes surrounding lines.

Path 1 is straightforward to block. Path 3 is partially blocked by the same mechanism (more on that below). Path 2 is the one that actually leaks production credentials in practice, and the only solid defense is OS-level sandboxing.

Why CLAUDE.md Alone Is Not Enough

Adding "never read .env files" to your CLAUDE.md is a reasonable starting move, but it is a soft instruction. Under ambiguous prompts or long context, soft instructions sometimes lose to other priorities.

Anthropic's own permissions docs explicitly position permissions.deny rules - not memory files - as the mechanism for excluding sensitive files: "To prevent Claude Code from accessing files containing sensitive information like API keys, secrets, and environment files, use the permissions.deny setting"[1]. Use enforcement, not instructions, for anything you cannot afford to leak.

A note on the GitHub issue narrative

Issue #24846[3] reports that deny rules failed to block .env.local. Looking at the bug report, the user wrote a malformed schema (permissions.read.deny: [...]) instead of the documented form (permissions.deny: ["Read(./.env)"]), and the issue was closed as a duplicate. Real bugs in deny enforcement have existed - issue #6631 from August 2025 documents a period where Read/Write deny patterns were not enforced[4] - but the current docs and recent versions support the syntax shown here. Use the official format and verify it works in your environment.

The Fix: Deny Rules in settings.json

Claude Code has a permissions system with three lists: allow, ask, and deny. Rules are evaluated in order - deny first, then ask, then allow - and the first matching rule wins[2]. This is the right place to declare files that no built-in tool should ever read.

Add this to ~/.claude/settings.json for global protection across every project:

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(**/.env)",
      "Read(**/.env.*)",
      "Read(**/.dev.vars*)",
      "Read(**/*.pem)",
      "Read(**/*.key)",
      "Read(**/secrets/**)",
      "Read(**/credentials/**)",
      "Read(~/.aws/**)",
      "Read(~/.ssh/**)",
      "Read(**/config/database.yml)",
      "Read(**/config/credentials.json)",
      "Read(~/.npmrc)",
      "Read(~/.pypirc)",
      "Edit(**/.env*)",
      "Edit(**/secrets/**)",
      "Edit(~/.ssh/**)"
    ]
  }
}

A few notes on syntax that the docs are explicit about[2]:

  • Pattern syntax follows the gitignore specification. * matches files in a single directory, ** matches recursively.
  • A bare /path is relative to the project root, not the filesystem root. Use ~/path for home directory and //path for true absolute paths. Use ./path for paths relative to the current directory. This trips people up.
  • Edit rules cover all built-in file edit tools. There is no separate Write rule type in the official permission rule reference - Edit is the canonical name.
  • The $schema URL is the JSON Schema Store entry and is what Anthropic's own example settings file uses[1]. Adding it gives you autocomplete and validation in any editor that supports JSON schema.

What Deny Rules Actually Block (And What They Do Not)

This is the part most write-ups gloss over, and Anthropic spells it out plainly.

Read and Edit deny rules apply to Claude's built-in file tools, not to Bash subprocesses. A Read(./.env) deny rule blocks the Read tool but does not prevent cat .env in Bash. For OS-level enforcement that blocks all processes from accessing a path, enable the sandbox.[2]

Three implications:

  • The Read deny rule does cover Grep and Glob in addition to the Read tool itself, because "Claude makes a best-effort attempt to apply Read rules to all built-in tools that read files like Grep and Glob"[2]. So path 3 (grep leaks) is largely mitigated.
  • It does not cover bash. If Claude runs cat .env, head .env, grep -r FOO ., or diff .env.example .env, those go through the Bash tool's stdout and bypass your Read deny rules.
  • Worse, several read-only Bash commands - ls, cat, head, tail, grep, find, wc, diff, stat, du, cd, and read-only forms of git - "run without a permission prompt in every mode"[2]. So cat .env can execute without you ever seeing an approval dialog.

If you only configure Read deny rules, you have closed paths 1 and 3 but left a wide-open hole on path 2 plus a decent-sized side door on path 1 via Bash. The next two sections close those.

Verify Your Deny Rules Actually Work

Do not trust your config until you have tested it. Per Anthropic's own UX, you can run /permissions inside Claude Code to see all active rules and where they came from[2], and /status to see which settings sources are loaded.

To verify enforcement, create a throwaway directory, drop a .env in it with fake values, open Claude Code there, and ask it to read the file via the Read tool. It should refuse. Also try cat .env through Bash. Note what gets through, then adjust.

Test Discipline

Never run this verification in a real project with real credentials. Create a scratch directory, populate it with dummy values, run the tests there.

Closing the Bash Hole

Two options: weak and strong.

Weak Option: Allow-List Bash

Do not grant blanket Bash access. Allow specific commands only. The permissions.allow list with patterns like Bash(npm run *) and Bash(git status) is the documented approach[2]. Anything not on the list goes through the normal permission flow.

Strong Option: Sandbox the Bash Tool

Claude Code ships with native OS-level sandboxing using Seatbelt on macOS and bubblewrap on Linux/WSL2[5]. This is the actual fix for path 2 (runtime output) and the bash hole. The sandbox restricts filesystem and network access at the kernel/OS level, which means it applies to every subprocess Claude spawns, not just commands the model considers running.

Enable it with /sandbox in the REPL or in settings.json:

{
  "sandbox": {
    "enabled": true,
    "autoAllowBashIfSandboxed": true,
    "filesystem": {
      "denyRead": ["~/.aws/credentials", "~/.ssh/**"]
    },
    "network": {
      "allowedDomains": ["github.com", "*.npmjs.org", "registry.yarnpkg.com"]
    }
  }
}

Per Anthropic's docs, sandbox denyRead paths are "merged with any paths from Edit(...) and Read(...) permission rules"[5], so your existing deny list extends naturally into the sandbox boundary. With sandboxing on, cat .env from within bash hits the same wall as the Read tool would.

Linux and WSL2 require bubblewrap and socat to be installed first[5]:

# Ubuntu/Debian
sudo apt-get install bubblewrap socat

# Fedora
sudo dnf install bubblewrap socat

macOS works out of the box.

Blocking Runtime Leaks at the Application Layer

Even with sandboxing, your application can still print secrets to stdout that Claude then captures as tool output. The simplest fix is to make sure the secrets the app sees during dev and test are not the real ones.

Use a separate .env.test with dummy values, and point your test framework at it:

# .env.test (safe to read, safe to leak, safe to commit if you want)
STRIPE_SECRET_KEY=sk_test_not_a_real_key
DATABASE_URL=postgres://test:test@localhost:5432/testdb
OPENAI_API_KEY=sk-test-dummy-key-for-mocking
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

For local dev with real credentials, consider a vault (1Password CLI, AWS Secrets Manager, Doppler) loaded at session start instead of a plaintext file. The agent never reads the file because the file does not exist on disk.

Why this works

If the only credentials your dev process ever sees are dummies, then the worst case for path 2 is that a dummy ends up in a transcript. Production keys never enter the loop because they were never on disk.

Catching Mistakes at Commit Time

Pre-commit hooks will not stop Claude from reading a secret, but they will stop you from pushing it to GitHub. Drop this in .git/hooks/pre-commit:

#!/bin/bash
# Blocks commits containing common secret patterns.

PATTERNS=(
  'sk-ant-api03-'        # Anthropic API keys (production format)
  'sk-live-'             # Stripe live keys
  'sk_live_'             # Stripe live keys (alt format)
  'rk_live_'             # Stripe restricted keys
  'ghp_'                 # GitHub personal tokens
  'gho_'                 # GitHub OAuth tokens
  'ghs_'                 # GitHub server tokens
  'AKIA[0-9A-Z]{16}'     # AWS access keys (full pattern)
  'xox[bpors]-'          # Slack tokens
  'SG\.[A-Za-z0-9_-]{22}\.' # SendGrid keys
  'BEGIN.*PRIVATE KEY'   # Private key material
)

BLOCKED_FILES=('.env' 'credentials.json' 'id_rsa' '.pem' '.key')

for pattern in "${PATTERNS[@]}"; do
  if git diff --cached --diff-filter=ACM | grep -qE "$pattern"; then
    echo "BLOCKED: staged change matches secret pattern '$pattern'"
    exit 1
  fi
done

for file in "${BLOCKED_FILES[@]}"; do
  if git diff --cached --name-only | grep -q "$file"; then
    echo "BLOCKED: attempted to commit sensitive file matching '$file'"
    exit 1
  fi
done

echo "Pre-commit security check passed."
exit 0

Make it executable:

chmod +x .git/hooks/pre-commit
Two sharp edges to know about

A regex for eyJ would match every JWT, including the public, harmless ones in your test fixtures. The pattern list above intentionally omits it. The AWS pattern AKIA[0-9A-Z]{16} will match the AKIAIOSFODNN7EXAMPLE placeholder shown in the .env.test sample - AWS publishes that string as the canonical fake key, but a strict regex does not know the difference. If you commit .env.test files, swap in AKIAEXAMPLEEXAMPLE12-style placeholders, add .env.test to your hook's allowlist, or skip the AWS pattern.

For team-wide enforcement, run gitleaks or trufflehog in CI. Local hooks help individuals, but only the people who installed them.

Org-Wide Policy: Managed Settings

For client work or any team where security policy needs to be non-negotiable, use managed settings instead of relying on each developer's ~/.claude/settings.json. Anthropic's docs are clear: managed settings "cannot be overridden by user or project settings" and sit at the top of the precedence chain, above command line arguments[1][2].

Managed settings can be deployed three ways[1]:

1

Server-Managed

From the Claude.ai admin console for Team and Enterprise plans.

2

MDM / OS Policies

macOS plist (com.anthropic.claudecode) or Windows registry (HKLM\SOFTWARE\Policies\ClaudeCode), deployable via Jamf, Kandji, Intune, or Group Policy.

3

File-Based

Drop managed-settings.json into /Library/Application Support/ClaudeCode/ (macOS), /etc/claude-code/ (Linux/WSL), or C:\Program Files\ClaudeCode\ (Windows).

Migration note

As of Claude Code v2.1.75, the legacy Windows path C:\ProgramData\ClaudeCode\managed-settings.json is no longer supported[1]. If you deployed there, migrate.

A managed-settings example for .env lockdown:

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(**/.env)",
      "Read(**/.env.*)",
      "Read(**/*.pem)",
      "Read(**/*.key)",
      "Read(~/.aws/**)",
      "Read(~/.ssh/**)",
      "Bash(curl *)",
      "Bash(wget *)"
    ],
    "disableBypassPermissionsMode": "disable",
    "allowManagedPermissionRulesOnly": true
  },
  "sandbox": {
    "enabled": true,
    "failIfUnavailable": true
  }
}

allowManagedPermissionRulesOnly: true prevents users and projects from defining their own allow/deny rules - only the managed rules apply[2]. disableBypassPermissionsMode: "disable" blocks the --dangerously-skip-permissions escape hatch[2]. sandbox.failIfUnavailable: true means Claude Code refuses to start if the sandbox cannot initialize, rather than silently falling back to unsandboxed mode[5].

A Complete Starting Config

For an individual developer who wants reasonable defaults without organizational policy, here is a defensible ~/.claude/settings.json:

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "permissions": {
    "allow": [
      "Read",
      "Glob",
      "Grep",
      "Edit(./src/**)",
      "Edit(./tests/**)",
      "Bash(npm run *)",
      "Bash(npm test *)",
      "Bash(npx tsc *)",
      "Bash(git status)",
      "Bash(git diff *)",
      "Bash(git log *)",
      "Bash(git add *)",
      "Bash(git commit *)"
    ],
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(**/.env)",
      "Read(**/.env.*)",
      "Read(**/.dev.vars*)",
      "Read(**/*.pem)",
      "Read(**/*.key)",
      "Read(**/secrets/**)",
      "Read(**/credentials/**)",
      "Read(~/.aws/**)",
      "Read(~/.ssh/**)",
      "Read(**/config/database.yml)",
      "Read(**/config/credentials.json)",
      "Read(~/.npmrc)",
      "Read(~/.pypirc)",
      "Edit(**/.env*)",
      "Edit(**/secrets/**)",
      "Edit(~/.ssh/**)",
      "Edit(.github/workflows/*)",
      "Bash(rm -rf *)",
      "Bash(sudo *)",
      "Bash(git push *)",
      "Bash(npm publish *)",
      "Bash(curl *)",
      "Bash(wget *)",
      "Bash(chmod *)"
    ],
    "defaultMode": "acceptEdits"
  },
  "sandbox": {
    "enabled": true,
    "autoAllowBashIfSandboxed": true,
    "filesystem": {
      "denyRead": ["~/.ssh/**", "~/.aws/credentials"]
    }
  }
}

A few notes on the choices in this config:

  • Bash(curl *) is in deny, not allow. Anthropic flags curl and wget as risky and notes that pattern-based bash restrictions are fragile[2]. Block them outright.
  • Edit(...) is the canonical rule type for built-in file edit tools per the official permission rule reference[2].
  • defaultMode: "acceptEdits" auto-accepts file edits and common filesystem commands like mkdir, touch, mv, cp within the working directory[2]. This is a productivity choice, not a security one. Bash commands with side effects, network calls, and anything matching a deny rule still go through normal evaluation.
  • Sandbox is on by default for the strongest protection on path 2.

The Actual Checklist

Deny rules in place

For .env, keys, and credentials in ~/.claude/settings.json, using the official Tool(specifier) syntax.

Bash constrained

Allow-list for safe commands; curl, wget, chmod, sudo, rm -rf in deny.

Sandbox enabled

Via /sandbox or sandbox.enabled: true. The only solid defense for path 2.

Tests use .env.test

Dummy values only. Real production credentials never on the dev machine in plaintext.

Secrets in a vault

1Password CLI, AWS Secrets Manager, Doppler. Loaded at session start, never on disk.

.gitignore + pre-commit

Local hook scans staged diffs for known secret patterns before they hit a remote.

CI secret scanning

gitleaks or trufflehog as a backstop. Catches what individual hooks miss.

Managed settings

For client / production work: deploy via MDM with allowManagedPermissionRulesOnly and sandbox.failIfUnavailable.

Where you stand

Items 1 to 6: you are in good shape. All eight: a security review will actually pass. Zero: your first ambiguous Claude prompt is one tool call away from putting an API key in a chat transcript.

A Final Note on Calibration

Security writing about AI tools tends toward catastrophizing. The honest framing, per Anthropic's own security docs: Claude Code uses strict read-only permissions by default and asks before running bash commands or modifying files[6]. It is not silently exfiltrating your secrets. It also has no way of knowing which strings you consider sensitive unless you tell it, in a form it cannot ignore.

The settings file is how you tell it. The sandbox is how you enforce it. Configure once, verify, move on.

Production AI security starts before Day Two

StronglyAI builds the controls before the demo, not after. Talk to a Forward Deployed Engineer about what your team's setup looks like in production.

Talk to an FDE this week

References

  1. Anthropic. "Claude Code settings." Claude Code Docs. (Settings file locations, schemas, managed settings delivery, scope precedence, and the excluding-sensitive-files example using permissions.deny.)
  2. Anthropic. "Configure permissions." Claude Code Docs. (Permission rule syntax, the Read/Edit/Bash tool model, the explicit note that Read deny rules do not apply to Bash subprocesses, the read-only bash command list, fragility of bash patterns, and managed-only settings.)
  3. RyanL2. "Read deny permissions in settings.json not enforced for .env files." GitHub issue #24846, anthropics/claude-code, Feb 11 2026 (closed as duplicate; reporter used the malformed permissions.read.deny schema instead of the documented permissions.deny: ["Read(./.env)"] form).
  4. anthropics/claude-code. "Permission Deny Configuration Not Enforced for Read/Write Tools." GitHub issue #6631, Aug 27 2025 (a real enforcement bug in CLI v1.0.93).
  5. Anthropic. "Sandboxing." Claude Code Docs. (Seatbelt on macOS, bubblewrap on Linux/WSL2, prerequisites, sandbox modes, filesystem and network isolation, merging of permission rule paths, and the failIfUnavailable setting for managed deployments.)
  6. Anthropic. "Security." Claude Code Docs. (Permission-based architecture, prompt injection protections, write-access restrictions, and the Windows WebDAV warning.)