How to stop Claude Code, Cursor and Codex reading your .env file
In short: .gitignore doesn't keep secrets away from a coding agent, and neither does a line in CLAUDE.md or AGENTS.md. Each tool has its own control. Claude Code uses Read deny rules plus the sandbox, Cursor uses .cursorignore plus a hook for terminal commands, and Codex uses a permission profile with deny globs. Each one leaves a gap, listed below. The real fix is not having production keys in the working tree.
Why "don't read .env" in your rules isn't enough
A rule in CLAUDE.md, AGENTS.md or a Cursor rule is guidance. The model usually follows it, but nothing enforces it. The Claude Code permissions docs say it directly: permission rules are enforced by Claude Code, not by the model, and instructions in your prompt or CLAUDE.md shape what Claude tries to do without changing what Claude Code allows. The same split exists in Cursor and Codex. Keep the rule (our security rules checklist covers what to write), and add the tool-level block below so a slip doesn't put a key into the conversation.
Three things people try that don't work:
.gitignore. It keeps the file out of commits. The agent works on your disk, so it can still open the file..claudeignore. Claude Code doesn't read it. Per the docs, the file has no effect, and its entries belong inReaddeny rules.- A made-up settings shape. A
"read": {"deny": [...]}block underpermissionsisn't the documented format. Deny rules are strings inpermissions.deny, in the formTool(specifier).
Claude Code: Read deny rules, then the sandbox
1. Deny the files
Put this in the project's .claude/settings.json and commit it, so everyone on the repo gets it:
{
"permissions": {
"deny": [
"Read(.env)",
"Read(.env.*)",
"Read(!.env.example)",
"Read(*.pem)",
"Read(*.key)"
]
}
}
How these match, per the path pattern docs:
- Patterns use gitignore syntax. A bare filename matches at any depth, so
Read(.env)is the same asRead(**/.env): any.envat or under the folder you started Claude in. It doesn't cover a.envin a parent folder or another project. - A rule starting with
!carves an exception out of the rules listed before it in the same file. Here,.env.examplestays readable so Claude can still see which variables exist. - A
Readdeny rule also blocks the Edit and Write tools on that path, including creating the file. - Deny rules are checked first. An allow rule can't reopen a path a deny rule blocks, and a deny in any settings file wins.
To cover every project on your machine, put rules in ~/.claude/settings.json with a // (filesystem root) or ~/ (home) anchor. A single leading / in user settings means ~/.claude/, not your project, so Read(/secrets/**) there blocks ~/.claude/secrets. The docs give Read(//**/.env) as the pattern for any .env on the filesystem.
2. Know what deny rules don't cover
The docs are explicit. Read deny rules apply to Claude's built-in file tools (Read, Grep, Glob, best effort), @file mentions, IDE selection context, the Bash file commands Claude Code recognizes (cat, head, tail, sed, tee) and redirect targets like < .env. They don't apply to:
- a command that reads files without naming them, such as
grep -r API_KEY .run from the folder that holds.env; - a script that opens the file itself, like
python3 -c "print(open('.env').read())"or your app's dev server printing its config.
3. Turn on the sandbox for shell commands
The sandbox is the OS-level layer for Bash commands and every process they start (Seatbelt on macOS, bubblewrap on Linux and WSL2; native Windows runs commands unsandboxed). It's off by default. Run /sandbox in a session, or set sandbox.enabled in settings. By default sandboxed commands can still read most of the machine, but the sandbox docs say paths from your permission rules, including Read and Edit deny rules, are merged into the sandbox configuration. So with the sandbox on, the .env rules above also apply to that Python one-liner.
A user-level setup in ~/.claude/settings.json:
{
"permissions": {
"deny": [
"Read(//**/.env)",
"Read(//**/.env.*)",
"Read(~/.ssh/**)",
"Read(~/.aws/**)"
]
},
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false,
"credentials": {
"envVars": [
{ "name": "OPENAI_API_KEY", "mode": "deny" },
{ "name": "STRIPE_SECRET_KEY", "mode": "deny" }
]
}
}
}
allowUnsandboxedCommands: falseturns off the escape hatch where Claude retries a failed command outside the sandbox. Without it, that retry prompts you in Manual mode, runs with no prompt inbypassPermissionsmode, and goes to the classifier in auto mode.credentials.envVarswith"mode": "deny"unsets those variables before each sandboxed command runs. There's no built-in credential list, so name the ones you have. To strip credentials from every subprocess (Bash, hooks, stdio MCP servers), setCLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1(env vars docs).- Hooks, MCP servers and status-line commands run outside the sandbox with your full access, so review what you install.
- On Linux and WSL2, the sandbox docs warn that a wildcard placed right after the filesystem root (their example:
/**/*.pem) blocks nothing at the sandbox level. The user-level//**/.envrules still guard Claude's file tools, but rely on the project-level rules, which are anchored in the project, for the sandbox, and test them.
Check it: run /permissions to see your deny rules and which file each came from. Then ask Claude to show you the contents of .env, and to run a one-line script that prints it. Both should fail.
Cursor: .cursorignore, plus a hook for the terminal
1. Ignore the files
Per the ignore file docs, files in .cursorignore are blocked from Agent, Tab and @-mention references. It uses gitignore syntax, including ! to re-include a file:
# .cursorignore (gitignore syntax)
.env
.env.*
!.env.example
*.pem
*.key
To cover every project, add the same patterns (**/.env, **/.env.*, **/*.pem, **/*.key) to the global ignore list in your user settings. The docs note it's empty by default.
Don't skip this because the docs also list .env* in Cursor's default ignore list. In a forum thread (March 2026), a Cursor team member explained that .gitignore and the default ignore list applied to indexing, not to the agent's Read tool or Tab, and pointed to .cursorignore as the control for agent access. An explicit entry works either way.
2. Cover the terminal with a hook
The same docs say the terminal and MCP tools used by Agent can't honor .cursorignore, so cat .env in a shell command still works. The agent security docs add that terminal commands need your approval by default. If you've allowlisted commands or use a run mode that approves them for you, add a beforeShellExecution hook. Cursor runs it before each shell command and accepts allow, deny or ask.
.cursor/hooks.json in the project:
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{
"command": ".cursor/hooks/block-secrets.py",
"matcher": "\\.env|\\.pem|\\.key|id_rsa|id_ed25519",
"failClosed": true
}
]
}
}
The matcher is a regex tested against the full command string, so the hook only runs for commands that mention a secret-looking name. Every other command never reaches it and keeps its normal approval flow. failClosed: true blocks the command if the script crashes or times out. Without it, Cursor lets the command through on those failures.
.cursor/hooks/block-secrets.py (run chmod +x on it; project hooks run from the project root):
#!/usr/bin/env python3
"""Cursor beforeShellExecution hook: deny shell commands that name a secrets file."""
import json, re, sys
cmd = json.load(sys.stdin).get("command", "")
# .env, .env.local, config/.env.production, *.pem, *.key, id_rsa, id_ed25519
SECRET = re.compile(r"(?<![\w.-])(\.env(\.[\w-]+)*|[\w.-]+\.(pem|key)|id_(rsa|ed25519))(?![\w.-])")
SAFE = {".env.example", ".env.sample", ".env.template"}
hits = [m.group(0) for m in SECRET.finditer(cmd) if m.group(0) not in SAFE]
if hits:
print(json.dumps({
"permission": "deny",
"user_message": f"Blocked: command touches {', '.join(sorted(set(hits)))}",
"agent_message": "That command reads or writes a secrets file, which is blocked. Use .env.example "
"for variable names and ask the user for anything else you need.",
}))
else:
print(json.dumps({"permission": "ask"})) # no secret named: leave the decision to Cursor's normal approval
It denies the command when it names .env, .env.local, config/.env.production, a .pem or .key file, or an SSH key. .env.example and lookalikes such as .envrc fall through to ask, which hands the decision back to Cursor's normal approval. Writing to .env (cp .env.example .env) is denied too, so do that step yourself. We tested the script on 13 sample commands with Python 3, using the input format from the hooks reference, but not inside Cursor itself. Watch the Hooks output channel the first time it fires. On Windows, Cursor runs hook commands through PowerShell, so the shebang line won't work there. Change the command to call python explicitly.
It's a text match, so it has the same blind spot as Claude Code's deny rules: a script that opens .env without naming it in the command isn't caught. Treat it as a guard against the common case, not a boundary.
Codex: a permission profile with deny globs
Codex enforces file access with its sandbox, and the permission profile docs describe how to deny reads inside a writable workspace. Profiles are marked beta. Put this in ~/.codex/config.toml:
# ~/.codex/config.toml
default_permissions = "no-secrets"
[shell_environment_policy]
ignore_default_excludes = false # drop env vars whose names contain KEY, SECRET or TOKEN
[permissions.no-secrets]
description = "Workspace writes, no .env or key files"
extends = ":workspace"
[permissions.no-secrets.filesystem]
glob_scan_max_depth = 4
"~/.ssh" = "deny"
[permissions.no-secrets.filesystem.":workspace_roots"]
"**/.env" = "deny"
"**/.env.*" = "deny"
"**/*.pem" = "deny"
"**/*.key" = "deny"
extends = ":workspace"keeps the built-in workspace profile (writes in the workspace, with.git,.agents,.codexand.awskept read-only) and adds your rules.denyblocks reads and writes and wins over broaderreadorwriteentries.- On Linux, WSL and native Windows, an unbounded
**deny glob needsglob_scan_max_depth. Matches are listed when each sandboxed process starts, so a long-running process (a dev server, a watcher) can read a matching file created after it started. .env.*also matches.env.example. The docs sayreadandwriteglobs are less portable than deny globs, so rather than try to reopen it, keep the example file under another name such asenv.exampleif Codex needs to see it.- Profiles don't combine with the older settings. If
sandbox_modeappears in any loaded config, or you pass--sandbox, Codex uses the old sandbox settings and ignoresdefault_permissions. - Profiles govern the local commands Codex runs in its sandbox. MCP servers, connectors, the browser and commands you approve to run outside the sandbox use their own controls, so don't approve an escalation that reads
.env. shell_environment_policyhandles the other leak. Per the advanced config docs,ignore_default_excludesdefaults totrue, which means Codex does not drop variables named like*KEY*,*SECRET*or*TOKEN*from the commands' environment unless you set it tofalse.
Check it: start a new session, ask Codex to print .env, and confirm it can't show you the contents.
The fix that doesn't depend on any tool
Every control above has a gap, and they all assume you remembered to set them up on this machine. Make the file less worth stealing:
- Keep production secrets off your laptop's working tree. Use development-only keys locally, with low limits and no access to real customer data.
- Load real secrets at run time from your platform's secrets store or a secrets manager, not from a file in the repo.
- Commit a
.env.examplewith every variable name and no values. The agent gets what it needs to write code without seeing a key. - Rotate any key an agent has printed. Once a value is in a transcript, treat it as exposed.
- Watch for copies. Worktree setups often copy
.envinto each new checkout. Commit.claude/settings.json,.cursorignoreand.cursor/hooks.jsonso every worktree gets them, and keep the Codex profile in~/.codex/config.toml, where it applies to whatever workspace you open. See parallel agents with git worktrees.
Five-minute checklist
- Claude Code:
Read(.env),Read(.env.*)andRead(!.env.example)in.claude/settings.json;/sandboxon; strict sandbox mode in user settings. - Cursor:
.envand.env.*in.cursorignoreand in the global ignore list; thebeforeShellExecutionhook if any commands run without your approval. - Codex: a
:workspace-based profile with.envdeny globs, selected withdefault_permissions;ignore_default_excludes = false. - Keep the "never read or print secrets" line in your always-on rules as the explanation, not the enforcement.
- Test each tool by asking it to show you
.env. Then rotate anything that ever leaked.
Rules files tell the agent what you want, and settings make it stick. The same split applies to tests and formatting; see why a long CLAUDE.md gets ignored for a Stop hook that blocks "done" while tests fail.
FAQ
How do I stop Claude Code from reading my .env file?
Add Read deny rules to .claude/settings.json, for example Read(.env) and Read(.env.*), which match at any depth under the folder you start Claude in. A .claudeignore file has no effect. Deny rules cover Claude's file tools and the Bash commands Claude Code recognizes, such as cat and head, but not a script that opens the file itself, so also turn on the sandbox with /sandbox; Claude Code merges Read deny rules into the sandbox's filesystem rules.
Does Cursor read .env files by default?
Don't rely on the default. The docs list .env* in Cursor's default ignore list, but a Cursor team member explained on the forum that the default list and .gitignore applied to indexing, not to the agent's file reads. Put .env and .env.* in .cursorignore or in the global ignore list. Neither stops the agent's terminal or MCP tools, so add a beforeShellExecution hook for commands that name the file.
How do I block .env files in Codex?
Use a permission profile in ~/.codex/config.toml that extends :workspace and sets a deny glob such as "**/.env" = "deny" under :workspace_roots, then select it with default_permissions. Profiles are beta, don't combine with sandbox_mode, and only govern the local commands Codex runs in its sandbox.
Is .gitignore enough to keep secrets away from an AI coding agent?
No. .gitignore keeps a file out of git, not out of the agent's reach: the agent works on your disk and can open the file anyway. In Cursor, staff described .gitignore as an indexing control. Use each tool's own deny mechanism: Read deny rules in Claude Code, .cursorignore in Cursor, and deny globs in a Codex permission profile.
What's the safest way to keep API keys away from coding agents?
Don't have production keys in the working tree at all. Use development-only keys locally, load real secrets from a secrets manager at run time, commit a .env.example with names but no values, and rotate any key an agent has printed. The tool settings in this article reduce accidents; they aren't a vault.
Sources
- Claude Code docs: Configure permissions (Read deny rules, path patterns)
- Claude Code docs: Configure the sandboxed Bash tool (filesystem rules, credentials)
- Claude Code docs: Environment variables (CLAUDE_CODE_SUBPROCESS_ENV_SCRUB)
- Cursor docs: Ignore file (.cursorignore, global ignore list)
- Cursor docs: Agent security
- Cursor docs: Hooks (beforeShellExecution, failClosed)
- Codex docs (ChatGPT Learn): Permissions (permission profiles, deny-read globs)
- Codex docs (ChatGPT Learn): Advanced configuration (shell_environment_policy)