
Securing MCP API keys in Claude Code (and why it's urgent)
The problem: plaintext keys in mcp.json
The typical content of an unsecured ~/.claude/mcp.json:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "my-mcp-server@latest"],
"env": {
"API_KEY": "sk-1234567890abcdef..."
}
}
}
}
Plaintext API keys. Not encrypted, not in a vault.
The "it's local, it's fine" argument doesn't hold. In February 2026, two CVEs landed on Claude Code (CVE-2025-59536 and CVE-2026-21852). The second was nasty: a malicious repo could override ANTHROPIC_BASE_URL in project settings and redirect all API traffic to a third-party server. Fixed since v2.0.65.
And you don't need a CVE to get burned: an over-enthusiastic git add ., a colleague copying your config, a shady npm script scanning ~/.claude/. A leaked API key can mean hundreds of euros of fraudulent usage before anyone notices.
${VAR} interpolation
Claude Code supports environment variables in ${} form inside mcp.json:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["-y", "my-mcp-server@latest"],
"env": {
"API_KEY": "${MY_SERVER_API_KEY}"
}
}
}
}
When the MCP server launches, Claude Code substitutes ${MY_SERVER_API_KEY} with the real value from the environment. The JSON file stays clean.
Setting it up, in 4 steps
1. A secrets file with the right permissions
# Create the file
cat > ~/.env.claude << 'EOF'
# API keys for Claude Code MCP servers
MY_SERVER_API_KEY="sk-1234567890abcdef"
OTHER_SERVICE_TOKEN="token_here"
SERVICE_EMAIL="my@email.com"
EOF
# Permissions: read/write for the owner only
chmod 600 ~/.env.claude
chmod 600 isn't optional: without it, any process on the machine can read the file.
2. Load the variables at shell startup
The variables must exist in the environment before Claude Code starts.
Fish (in ~/.config/fish/config.fish) — verbose, because fish doesn't source .env files natively:
# Load Claude MCP secrets
if test -f ~/.env.claude
for line in (grep -v '^#' ~/.env.claude | grep '=')
set -l key (echo $line | cut -d= -f1)
set -l val (echo $line | cut -d= -f2- | tr -d '"')
set -x $key $val
end
end
Bash/zsh (in ~/.bashrc or ~/.zshrc):
# Load Claude MCP secrets
[ -f ~/.env.claude ] && set -a && source ~/.env.claude && set +a
set -a auto-exports every sourced variable, avoiding an export per line, and set +a restores normal behaviour afterwards.
3. Clean up mcp.json
{
"mcpServers": {
"search-server": {
"command": "npx",
"args": ["-y", "mcp-search@latest"],
"env": {
"SEARCH_API_KEY": "${SEARCH_API_KEY}"
}
},
"management-server": {
"command": "npx",
"args": ["-y", "mcp-management@latest"],
"env": {
"MANAGEMENT_API_KEY": "${MANAGEMENT_API_KEY}",
"MANAGEMENT_TOKEN": "${MANAGEMENT_TOKEN}"
}
},
"ai-server": {
"command": "uvx",
"args": ["mcp-ai-server"],
"env": {
"AI_API_KEY": "${AI_API_KEY}"
}
}
}
}
Anyone opening this file now sees variable names, nothing else.
4. Deny rules, belt and braces
Claude Code can read any file on the machine, including ~/.env.claude. A hallucination or a prompt injection through a dodgy MCP would be enough to send it looking. In ~/.claude/settings.json:
{
"permissions": {
"deny": [
"Edit(~/.env.claude)",
"Read(~/.env.claude)",
"Edit(~/.ssh/**)",
"Edit(~/.aws/**)",
"Read(~/.ssh/id_*)",
"Read(~/.aws/credentials)"
]
}
}
Defense in depth: even if everything else fails, reading the file hits a wall.
Verifying it works
Restart your shell (exec fish / source ~/.bashrc), then:
# Are the variables loaded?
echo $MY_SERVER_API_KEY
# Should print the key
# Is the secrets file protected?
ls -la ~/.env.claude
# Should show -rw------- (600)
# Any hardcoded secrets left in mcp.json?
grep -c "sk-\|token_\|AIza" ~/.claude/mcp.json
# Should print 0
If an MCP server refuses to start, the variable probably isn't exported into the environment — env | grep MY_SERVER confirms it.
A few extras
Key rotation now means changing one line in ~/.env.claude and restarting the shell, instead of editing JSON and hoping not to break the syntax.
Multiple environments: nothing stops you keeping ~/.env.claude.personal, ~/.env.claude.work, ~/.env.claude.client-x and loading the right one via an alias.
The .gitignore, if you keep a .claude/ folder in a versioned project:
# Claude Code
.env.claude*
.claude/settings.local.json
HTTP Hooks: since early 2026, HTTP hooks ("type": "http") also interpolate environment variables through allowedEnvVars. Same reflex — never a hardcoded token, and an allowed-variable list kept to the strict minimum.
Recap
~/.env.claude— every key, chmod 600mcp.json— only${VAR}, zero secretssettings.json— deny rules on the secrets file- Shell config — automatic loader at startup
Given February's CVEs and the growing access AI agents have to our machines, securing MCP keys isn't excessive caution but basic hygiene — and it takes fifteen minutes.
Related articles