Skip to content

Keep Secrets Out of claude_desktop_config.json (No env Interpolation)

TL;DR — Claude Desktop's claude_desktop_config.json stores each MCP server's env block 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 a chmod 600 env file and launch the affected servers through a tiny wrapper that sources the file and execs 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-password can trigger a macOS Keychain access prompt that blocks the server's launch until a human clicks Allow. A sourced 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):

NAMECHEAP_API_KEY='…'
MAIL_IMAP_MAJORMAIL_PASS='…'
MAIL_SMTP_MAJORMAIL_PASS='…'

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 0600 config 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.bak written 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" (no set -a/set +a) sources without any error, but the assignments stay shell-local and never reach the exec'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 trust echo "loaded ok" from inside the wrapper — test with ~/.local/bin/mcp-with-secrets.sh bash -c 'echo ${YOUR_VAR:+yes}' and confirm it prints yes, or better, run the real MCP server through the wrapper and check it can actually authenticate.