Fransys

Tech blog — Architecture, Cloud & DevOps

BlogServicesContactAbout

Follow me

githubGitHublinkedinLinkedinmailMail

© 2026 Fransys • Fransys

Fransys

Categories

  • All posts
  • Tags
  • productivity10
  • nas10
  • ai8
  • security7
  • self-hosting7
  • linux6
  • claude-code6
  • neovim5
  • docker5
  • editor4
  • networking4
  • mcp3
  • vpn3
  • lua2
  • terminal2
claude-codesecuritymcpai

Securing MCP API keys in Claude Code (and why it's urgent)

Published on
March 15, 2026·6 min read
Avatar François GUERLEZFrançois GUERLEZ

Open your mcp.json. Look closely.

A few days ago, while writing an article about my Claude Code config, I stopped dead. I had my ~/.claude/mcp.json right in front of me, ready to paste into a code block for the post. And there they were. My API keys. In plain text. In a JSON file that's been sitting on my machine for months.

Like, literally this:

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "my-mcp-server@latest"],
      "env": {
        "API_KEY": "sk-1234567890abcdef..."
      }
    }
  }
}

API keys in plain text. Not encrypted. Not in a vault. Just... there.

I almost published that.

Why "it's local, it's fine" doesn't cut it

You might think: it's on my machine, no big deal. Except no. In February 2026, two CVEs dropped on Claude Code (CVE-2025-59536 and CVE-2026-21852). The second one was nasty: a malicious repo could override ANTHROPIC_BASE_URL in project settings and redirect all API traffic to a third-party server. Your keys, silently siphoned off. Fixed since v2.0.65, but still - that's a wake-up call.

And you don't need a CVE to get burned. One overzealous git add . and your mcp.json ends up on GitHub. Or a coworker peeks at your config for "inspiration". Or some dodgy npm script scans ~/.claude/. A leaked API key can mean hundreds of dollars in fraudulent usage before you even notice. I've seen Reddit threads with $400 bills because an OpenAI key was sitting in a dotfile.

Anyway. Let's fix this.

${VAR} interpolation: the feature nobody uses

Claude Code supports a dead simple syntax in mcp.json: environment variables wrapped in ${}. Instead of hardcoding the key, you put a reference:

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["-y", "my-mcp-server@latest"],
      "env": {
        "API_KEY": "${MY_SERVER_API_KEY}"
      }
    }
  }
}

When the MCP server starts, Claude Code swaps ${MY_SERVER_API_KEY} for the actual value from the environment. The JSON file stays clean. No secrets in it.

This has been around for a while, but I never bothered setting it up. Laziness, probably. Fifteen minutes of work and I could've slept easy for months.

How I did it (4 steps)

Step 1: a secrets file with proper permissions

All keys in one file, locked down:

# 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 only for the owner
chmod 600 ~/.env.claude

The chmod 600 isn't optional. Without it, any process on your machine can read the file. With it, only your user has access.

Step 2: load the variables on shell startup

The variables need to exist in the environment before Claude Code starts. So we source them in the shell profile.

Fish (in ~/.config/fish/config.fish):

# 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

A bit verbose because fish can't natively source .env files (thanks fish, as usual).

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 variable that gets sourced. No need to write export before each line. set +a turns that off afterwards so it doesn't pollute the rest.

Step 3: clean up mcp.json

Replace every hardcoded value with its variable:

{
  "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}"
      }
    }
  }
}

If someone opens this file now, they see variable names. That's it.

Step 4: deny rules for belt and suspenders

This one I added after the fact, in full paranoia mode. Claude Code can read any file on your machine. Including ~/.env.claude. It'd be a shame if a hallucination or a prompt injection through a sketchy MCP pushed it to go digging in there.

In ~/.claude/settings.json, we explicitly block it:

{
  "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, Claude hits a wall trying to read the file.

Does it actually work?

Restart your shell (or exec fish / source ~/.bashrc) and check:

# Are the variables loaded?
echo $MY_SERVER_API_KEY
# Should print the key

# Is the secrets file properly protected?
ls -la ~/.env.claude
# Should show -rw------- (600)

# No more hardcoded secrets in mcp.json?
grep -c "sk-\|token_\|AIza" ~/.claude/mcp.json
# Should print 0

Fire up Claude Code, test your MCP servers. If a server won't start, it's probably because the variable isn't exported in the environment. Quick env | grep MY_SERVER to check.

Worked first try for me. Miracle.

A few more things

Key rotation: now, renewing a key is changing one line in ~/.env.claude and restarting your shell. Before, you had to go edit the JSON, remember the right field, pray you don't break the syntax. Much better.

Multiple environments: if you juggle between personal projects and client work, nothing stops you from having ~/.env.claude.personal, ~/.env.claude.work, ~/.env.claude.client-x and loading the right one via an alias or a variable. Haven't needed it yet, but the day I do, the plumbing is there.

The .gitignore: if you have a .claude/ folder in a versioned project, add this right away:

# Claude Code
.env.claude*
.claude/settings.local.json

HTTP Hooks: since early 2026, Claude Code supports HTTP hooks ("type": "http"). These hooks also interpolate environment variables via allowedEnvVars. Same reflex: never hardcode an auth token in the hook config. Keep the list of allowed variables to the strict minimum.

Four files, fifteen minutes

Quick recap:

  1. ~/.env.claude - all the keys, chmod 600
  2. mcp.json - nothing but ${VAR}, zero secrets
  3. settings.json - deny rules on the secrets file
  4. Shell config - auto-loader on startup

Given the February CVEs and the fact that AI agents have access to more and more stuff on our machines, securing your MCP keys isn't overkill. It's basic hygiene. And honestly, fifteen minutes for peace of mind? Worth it.

Previous post

← Internet censorship: technical solutions to stay connected

Next post

Building a near real-time topic monitoring system on a VPS→
← Back to blog

Table of Contents

  • Open your mcp.json. Look closely.
  • Why "it's local, it's fine" doesn't cut it
  • ${VAR} interpolation: the feature nobody uses
  • How I did it (4 steps)
  • Step 1: a secrets file with proper permissions
  • Step 2: load the variables on shell startup
  • Step 3: clean up mcp.json
  • Step 4: deny rules for belt and suspenders
  • Does it actually work?
  • A few more things
  • Four files, fifteen minutes