I had been using Claude Code for a while when I decided it was time to audit everything that had accumulated around it: CLAUDE.md, permission rules, and the rest of the settings.
So I did what most of us would probably do. I asked Claude to help me review them.
One finding stood out:
“You have database credentials stored in your settings file.”
They were development credentials, and there had been no breach. But the way they got there exposed a flaw in my workflow.
Several times, I had asked Claude to connect to a development database and perform a specific task, telling it that the credentials were available in .env. Claude generated the command and asked for permission. I recognized the command and what it was intended to do, so I selected “Yes, always” to avoid approving the same operation repeatedly.
What I had not considered was how that approval would be recorded. The command was persisted after the credentials had been resolved, so the settings file contained the complete command—including the credentials in plaintext.
That was already a problem in development. But it immediately raised a harder question:
What happens when the issue you need help debugging exists only in production?
There will be cases where we want an AI coding agent to inspect logs, query a database, or trace an integration problem. That does not mean it should receive a production password or unrestricted credentials.
At that point, I stopped treating secrets as only a file-storage problem. Moving them from .env into another file would simply relocate the risk.
AI coding agents should be given the access they need, not the credentials that provide it.
That is the boundary this article is about.
The three changes that matter
1- Block direct access to secret-bearing files.
Claude Code supports Read deny rules, and they are useful. But Anthropic’s own documentation describes some of that enforcement as best-effort. The rules cover Claude’s file tools and shell commands it recognizes, but not every arbitrary subprocess that might open a file. If the boundary needs to apply to child processes as well, enable the sandbox and use its filesystem restrictions.
2- Keep secrets out of the environment that launches the agent.
Environment variables move from parent processes to their children. If I export a production token and then start Claude Code from that terminal, Claude inherits it. A better pattern is to start the agent from a clean shell and inject a secret only into the specific application or command that needs it.
That reduces ambient exposure, but it is not complete isolation. If the agent can change code that later runs inside the secret-bearing process, it may still cause that process to print or transmit the value. For stronger separation, the agent needs a sandbox or development environment that never receives production credentials in the first place.
3- Change is the one with the greatest impact: give the agent a separate, least-privilege identity.
For a database, that may be a non-production role with access only to the schemas or rows needed for the task. For an API, it may be a narrowly scoped token with a short expiry. Read-only is useful, but it is not harmless; read access to customer or production data can still be a serious breach. Where production access is unavoidable, restrict schemas, rows, and columns, exclude PII where possible, and audit the queries made through that identity.
File and process controls reduce the probability of exposure. Least-privilege identities limit the consequence. You need both.
For the local store in this example, I use macOS Keychain. It is already on the machine, managed by the operating system, and stores the secret encrypted rather than leaving it in a plaintext project file. The same pattern can use 1Password, Doppler, HashiCorp Vault, or a cloud secret manager.
Want the agent to build the setup? Give it this prompt
The non-negotiable rule is that the agent generates the scripts and configuration snippets. It never reads the existing secret values.
You are helping me reduce secret exposure on my Mac while using an AI coding
agent such as Claude Code.
HARD RULE: Never read, open, print, echo, or request the contents of .env files,
key files, password files, Keychain output, or any secret value. Generate scripts
and configuration snippets that I will review and run myself. Verify key names
and exit codes only, never values.
Implement the following:
1. Generate a permissions.deny snippet for ~/.claude/settings.json that blocks
Read access to .env files, *.pem, *.key, secrets directories, and any other
secret-bearing paths I list. Do not read my existing settings file.
2. Generate a Claude Code sandbox snippet that enables filesystem isolation,
denies those same paths at the OS level, and disables unsandboxed fallback.
Tell me which paths must be adjusted if projects live outside my home folder.
3. Generate a local audit script that checks ~/.claude/settings.json,
~/.claude.json, project settings, and .mcp.json files for likely literal
credentials. The script may report file names, JSON paths, and key names,
but never values. I will run it myself.
4. For project .mcp.json files, generate a local sanitization script that I will
run myself. It may replace literal credentials with ${VAR} references where
supported, but it must never print the original values. Explain that this
removes plaintext from the config but does not, by itself, keep the variable
outside the agent process. Prefer OAuth. If the MCP server only supports an
API key, generate a fixed per-server launcher outside the writable project.
It must retrieve one scoped Keychain item and execute one fixed server
command. Do not expose it to the agent as a general shell tool.
5. Create ~/bin/with-secrets. It must accept secret names, then "--", then a
command. It should retrieve each value from macOS Keychain and inject it only
into the command it launches.
6. Generate migrate-env-to-keychain.sh for simple, one-line KEY=value files.
I will run it myself. It must print key names only and reject malformed names.
7. Generate SQL for a Postgres login role named readonly_agent. Grant only the
required CONNECT, schema USAGE, and table SELECT privileges. Include ALTER
DEFAULT PRIVILEGES for the role that actually owns future tables. Do not use
a production database unless I explicitly confirm it.
8. Generate a secrets policy for ~/.claude/CLAUDE.md. I will append it myself.
9. Give me a verification checklist: a denied file read must fail; the app must
start through with-secrets; a missing Keychain item must fail before startup;
and INSERT, UPDATE, DELETE, CREATE, and role escalation must fail for
readonly_agent.
At the end, remind me to keep a placeholder-only env.example file, remove local
secret-bearing .env files after testing, and rotate every credential that may
have been exposed.
There is a deliberate limitation in that prompt: it asks the agent to create an audit script rather than inspect the files itself. An agent cannot search a secret-bearing configuration for literal values without reading them.
The local setup
1. Block the obvious paths
At minimum, add Read deny rules for the secret-bearing files in the projects where you use the agent:
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(**/.env*)",
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(**/secrets/**)"
]
}
}
Then configure the Claude Code sandbox to deny those paths at the filesystem level. The distinction matters: Read rules reduce exposure through Claude’s normal tools, while sandbox rules also constrain arbitrary child processes.
Claude Code v2.1.187 and later can deny explicitly listed credential files and environment variables through sandbox.credentials. Environment-variable masking requires v2.1.199 or later and additional network configuration. I would use those controls where they fit. I still keep the Keychain pattern because it is not tied to one coding agent, and the two layers can work together.
Broad .env* rules will also hide .env.example. That may be exactly what you want, but if the agent needs a list of required variable names, keep a placeholder-only template under a readable name such as env.example. Never put real values in it.
2. Put the values in Keychain
Add a secret from your own terminal:
security add-generic-password -a "$USER" -s STRIPE_KEY -w
With no value placed after -w, macOS prompts for it. The secret does not become part of the command in your shell history.
To retrieve it, a trusted script can use:
security find-generic-password -a "$USER" -s STRIPE_KEY -w
That command prints the secret, which is why the agent should not run it directly.
On the first read, macOS may show a Keychain permission dialog. I use Allow for that request rather than Always Allow. The important point is not to approve a Keychain request that the agent initiated without first understanding the command it is trying to run.
3. Inject a secret into one command
This is the wrapper I use for local development:
#!/usr/bin/env bash
# ~/bin/with-secrets
# Usage: with-secrets KEY1 KEY2 -- <command...>
set -euo pipefail
if [ "$#" -lt 3 ]; then
echo "usage: with-secrets KEY1 [KEY2 ...] -- <command...>" >&2
exit 2
fi
keys=()
while [ "${1:-}" != "--" ]; do
[[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || {
echo "invalid environment variable name: $1" >&2
exit 2
}
keys+=("$1")
shift
done
shift
[ "$#" -gt 0 ] || {
echo "missing command after --" >&2
exit 2
}
for key in "${keys[@]}"; do
value="$(security find-generic-password -a "$USER" -s "$key" -w)"
printf -v "$key" '%s' "$value"
export "$key"
unset value
done
exec "$@"
chmod +x ~/bin/with-secrets
with-secrets STRIPE_KEY DATABASE_URL -- npm run dev
The values never enter the shell that launched the agent. They exist in the launched application and anything that application starts, then disappear when that process tree exits.
That last sentence is also the boundary of the pattern. Every process in that tree can access the values. If the agent controls the code running there, use development-only credentials and assume the application could expose them.
4. Migrate a straightforward .env file
Run this only after closing the agent session, from your own terminal.
There is one tradeoff worth making explicit: automating security add-generic-password with -w “$value” places the value briefly in that command’s process arguments. If another same-user process inspecting arguments is part of your threat model, do not use this convenience script. Add each item interactively with security … -w, use Keychain Access, or use a small tool that calls the Keychain API directly.
#!/usr/bin/env bash
# migrate-env-to-keychain.sh
# Usage: ./migrate-env-to-keychain.sh [.env]
# Supports simple, one-line KEY=value entries only.
set -euo pipefail
env_file="${1:-.env}"
while IFS= read -r line || [ -n "$line" ]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line//[[:space:]]/}" ]] && continue
line="${line#export }"
[[ "$line" == *=* ]] || {
echo "skipped malformed line" >&2
continue
}
key="${line%%=*}"
value="${line#*=}"
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || {
echo "skipped invalid key name" >&2
continue
}
if [[ "$value" == \"*\" && "$value" == *\" ]]; then
value="${value:1:${#value}-2}"
elif [[ "$value" == \'*\' && "$value" == *\' ]]; then
value="${value:1:${#value}-2}"
fi
security add-generic-password -U -a "$USER" -s "$key" -w "$value"
echo "stored: $key"
unset value
done < "$env_file"
echo "Test the app, remove the secret-bearing file, and rotate exposed keys."
This parser is intentionally limited. Dotenv files can contain multiline values, substitutions, whitespace rules, and quoting edge cases. If yours does, add the entries manually in Keychain Access or use a parser you already trust. Do not make a migration script more “helpful” by evaluating the file as shell code.
After migration, keep env.example with names and placeholders for documentation. Remove only the files that contain actual values.
A policy the agent can follow
I also keep the following at user level in ~/.claude/CLAUDE.md, so it applies across projects:
## Secrets policy
- Never read, print, echo, request, or reproduce secret values from .env files,
key files, password files, Keychain, process environments, or tool output.
- Never write literal secrets to settings, MCP configuration, code, scripts,
logs, documentation, or transcripts.
- Do not run env, printenv, set, or commands that dump a process environment.
- Treat ${VAR} references as configuration hygiene, not proof that the value is
isolated from the agent.
- Use development-only, least-privilege identities for agent-accessible systems.
- If a task appears to require a secret value, stop and ask me to perform that
step outside the agent session.
Instructions in CLAUDE.md help the agent make better decisions. They are not a security boundary, so they sit behind permissions, sandboxing, and scoped identities rather than replacing them.
What changes in the application?
Usually, very little.
Applications consume environment variables; .env and dotenv are only ways of populating them. process.env.STRIPE_KEY receives the same value whether it came from a file, Keychain, or a secret manager.
The main change is how the application starts:
with-secrets STRIPE_KEY DATABASE_URL -- npm run dev
Some frameworks load .env files automatically, and some projects explicitly fail when a file is missing. Those cases need a small configuration change. The application should also validate its required environment variables at startup rather than failing later with an unrelated error.
For MCP configuration, ${VAR} references are still better than literal tokens in .mcp.json, but they solve a narrower problem: they keep the value out of the file. The variable still has to be supplied to the MCP server.
Where possible, I prefer OAuth. For an API-key-only MCP server, I use a trusted per-server launcher that retrieves one scoped Keychain item and executes one fixed server command. The launcher lives outside the writable project and is not available to the agent as a general shell tool. The MCP server and its package supply chain then become part of the trusted boundary.
What this pattern does and does not solve
This setup reduces several common forms of accidental exposure:
- Secrets no longer sit in plaintext project files.
- The agent does not automatically inherit everything from the developer’s shell.
- Normal file reads are denied, and sandboxing can extend that boundary to child processes.
- Any identity available to the agent can be limited and audited separately.
It does not make an agent-controlled process trustworthy, turn read-only access into harmless access, or remove the need to rotate a credential that may already have been exposed.
That is the broader lesson for me. Agent-ready architecture does not start with MCP. It starts with what the agent can see, what it can do, and how much damage one mistake can cause.
