Keep Secrets Out of claude_desktop_config.json (No env Interpolation)¶
TL;DR — Claude Desktop's
claude_desktop_config.jsonstores each MCP server'senvblock as literal strings — there is no${VAR}interpolation and no secret store. Any API key or password you put there sits in plaintext. Move the secrets into achmod 600env file and launch the affected servers through a tiny wrapper thatsources the file andexecs the real command. The config keeps only non-secret values and becomes safe to share/back up.
The problem¶
An MCP server that needs a credential is usually configured like this:
"mail": {
"command": "/path/to/mail-mcp",
"env": {
"MAIL_IMAP_PASS": "hunter2",
"MAIL_SMTP_PASS": "hunter2"
}
}
Claude Desktop passes each env value to the child process verbatim. It does not expand ${MAIL_IMAP_PASS} or read from macOS Keychain / a secrets manager. So the only way to feed a secret through the env block is to paste the plaintext — which then lives in the config file (and in any backup or screen-share of it).
Why not Keychain directly? A GUI-spawned helper (the MCP child) calling
security find-generic-passwordcan trigger a macOS Keychain access prompt that blocks the server's launch until a human clicks Allow. Asourced env file has no such failure mode and is fully testable from a shell.
The fix — wrapper + 0600 env file¶
1. Put the secrets in a locked-down env file outside any synced/git location:
~/.config/claude/mcp-secrets.env (chmod 600, dir chmod 700):
2. Add one generic wrapper that sources the file and execs whatever it's handed. It carries no secrets, so 755 is fine:
~/.local/bin/mcp-with-secrets.sh
#!/bin/bash
set -euo pipefail
SECRETS="$HOME/.config/claude/mcp-secrets.env"
if [ -f "$SECRETS" ]; then
set -a; . "$SECRETS"; set +a
fi
# GUI-launched shells get a minimal PATH — restore common tool dirs (e.g. uv)
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
exec "$@"
3. Repoint each secret-bearing server at the wrapper, moving the real command into args, and delete the secret keys from env:
"mail": {
"command": "/Users/you/.local/bin/mcp-with-secrets.sh",
"args": ["/path/to/mail-mcp"],
"env": {
"MAIL_IMAP_MAJORMAIL_HOST": "mail.example.com",
"MAIL_IMAP_MAJORMAIL_USER": "you@example.com"
// …no *_PASS keys here anymore…
}
},
"namecheap": {
"command": "/Users/you/.local/bin/mcp-with-secrets.sh",
"args": ["uv", "run", "/path/to/namecheap_server.py"],
"env": {
"NAMECHEAP_API_USER": "you",
"NAMECHEAP_CLIENT_IP": "…"
// …no NAMECHEAP_API_KEY here…
}
}
Non-secret settings (hosts, ports, usernames, *_WRITE_ENABLED flags) stay in the config env — only credentials move to the env file.
Verify¶
# 1. Config is still valid JSON
python3 -m json.tool ~/Library/Application\ Support/Claude/claude_desktop_config.json >/dev/null && echo valid
# 2. No secret value survives in the config (compare against the env file; prints counts only)
# 3. The wrapper loads secrets and resolves tooling — without printing any secret:
~/.local/bin/mcp-with-secrets.sh bash -c '
echo "key set: ${NAMECHEAP_API_KEY:+yes}"
echo "uv: $(command -v uv || echo MISSING)"'
Then fully quit and reopen Claude Desktop (Cmd+Q) so it re-reads the config, and confirm the servers connect.
Notes & follow-ups¶
- At-rest encryption: the env file is still plaintext on disk — same protection class as the
0600config it replaced. The wins are centralization (one place to rotate), a shareable config, and owner-only perms enforced explicitly. For encryption at rest, a Keychain-backed variant is possible but must pre-authorize the reader (security … -T /usr/bin/security+ partition-list) to avoid the launch-blocking prompt. - Rotate after exposure: if a secret was ever pasted in plaintext (config, a backup, a terminal, a screen-share), moving it does not un-expose it — rotate the credential at the source.
- Backups leak too: delete any
claude_desktop_config.json.bakwritten before the migration; it still contains the plaintext. - Verify the wrapper actually exports, not just that it sources cleanly (2026-08-28). A deployed copy of the wrapper below can drift from this article without erroring — a bare
source "$SECRETS"(noset -a/set +a) sources without any error, but the assignments stay shell-local and never reach theexec'd child's environment, so the MCP server starts with the credential silently missing. Confirmed on MajorMac: the wrapper installed 07-30 for the Namecheap effort had exactly this bug and was never caught, because nothing had actually exercised it end-to-end yet. Don't trustecho "loaded ok"from inside the wrapper — test with~/.local/bin/mcp-with-secrets.sh bash -c 'echo ${YOUR_VAR:+yes}'and confirm it printsyes, or better, run the real MCP server through the wrapper and check it can actually authenticate.
Related¶
- claude-desktop-remote-mcp-url-skipped — the other Desktop config gotcha (remote servers need
mcp-remote) - mail-mcp-setup — the MajorMail MCP this pattern secures