Securing MCP Server Secrets with macOS Keychain

Red lobster representing security for MCP server secrets

Not all MCP servers need secrets. Local tools (filesystem, database) might not need any credentials. Others use OAuth — Claude Code handles the auth flow and you never touch API keys.

But if your MCP server needs an API key or token, here’s how to handle it without exposing secrets.

The Risks

Before choosing an approach, understand what you’re actually protecting against:

1. Accidental Git Commits

One git add . and your API keys are on GitHub. If your repo is public, your secrets are immediately exposed.

2. Sharing Configs

You paste your .mcp.json in a blog post, Stack Overflow answer, or Slack message. If secrets are hardcoded, they’re now public.

3. Team Onboarding

New team member needs to set up MCP servers. If secrets are in config files, you’re emailing API keys around or committing them to a private repo that might become public.

4. Data Sent to Anthropic

When Claude reads your files, that content is sent to Anthropic’s servers for processing. You may not want your production API keys on a third party’s servers.

5. Prompt Injection Attacks

A malicious repository or webpage could contain hidden instructions that trick Claude into revealing secrets in its output. If secrets are in Claude’s context, they’re vulnerable. See Blocking Claude from Reading Secret Files below for real-world examples of this happening at scale.

6. Accidental Disclosure in Output

Claude might “helpfully” include your API key in a code suggestion, debug output, or commit message.

7. Echo/Print Tricks

Even with file read denied, Claude could run echo $API_KEY or printenv to access environment variables. File permissions don’t protect against this.

8. Unlocked Computer

You step away from your desk. Someone (or something) accesses your machine. Plaintext secrets in config files are immediately readable. Keychain actually helps here — it requires authentication to access.

9. Shared Machine Access

Multiple people or processes share your machine. Plaintext files are readable by anyone with file access.

The Standard Approach (And Why It’s Risky)

Hardcoding secrets in .mcp.json:

{
  "mcpServers": {
    "cloudflare": {
      "command": "node",
      "args": ["server.js"],
      "env": {
        "API_TOKEN": "sk-abc123-actual-secret"
      }
    }
  }
}

This fails on almost every risk above.

The Solution: Environment Variable Expansion

Claude Code supports environment variable expansion in .mcp.json:

{
  "mcpServers": {
    "cloudflare": {
      "command": "node",
      "args": ["server.js"],
      "env": {
        "API_TOKEN": "${CLOUDFLARE_API_TOKEN}"
      }
    }
  }
}

Syntax: ${VAR} or ${VAR:-default}
Works in: command, args, env, url, headers

Now your .mcp.json is safe to commit, share, and let Claude read. But where does the actual secret live?

Where to Store the Actual Secret

Option 1: .env File (Simple)

# In .env (add to .gitignore!)
CLOUDFLARE_API_TOKEN=your-actual-token-here

Protects against: Git commits (if gitignored), sharing configs
Doesn’t protect against: Claude reading it, prompt injection, unlocked computer

Warning: Claude auto-loads .env files without telling you.

Option 2: .zshrc (Outside Project)

# In ~/.zshrc
export CLOUDFLARE_API_TOKEN="your-actual-token-here"

Protects against: Git commits, sharing configs
Doesn’t protect against: Claude reading it, unlocked computer

Option 3: Doppler (Teams)

Doppler stores secrets in the cloud with native MCP integration:

{
  "mcpServers": {
    "cloudflare": {
      "command": "doppler",
      "args": ["run", "--project", "my-project", "--config", "dev", "--", "node", "server.js"]
    }
  }
}

Protects against: Git commits, sharing, team onboarding, data to Anthropic, prompt injection, unlocked computer
Doesn’t protect against: Echo tricks (secret is in env at runtime)

Good for teams: Access controls, audit trails, auto-rotation, free tier

Option 4: macOS Keychain

Keychain is an encrypted database, not a file. Requires authentication to access.

Store:

security add-generic-password -a "cloudflare" -s "api-token" -w "your-token"

Use in MCP:

{
  "mcpServers": {
    "cloudflare": {
      "command": "/bin/sh",
      "args": ["-c", "CLOUDFLARE_API_TOKEN=$(security find-generic-password -a cloudflare -s api-token -w) exec node server.js"]
    }
  }
}

Protects against: Git commits, sharing, data to Anthropic, prompt injection, unlocked computer (requires auth)
Doesn’t protect against: Echo tricks

Comparison

Approach Git Share config Team onboard To Anthropic Unlocked Mac
Hardcoded
.env (gitignored)
.zshrc
Doppler
Keychain

Which Should You Use?

Just want safe git commits? .env + .gitignore

Working alone, want real security? Keychain (protects even if you leave your Mac unlocked)

Working in a team? Doppler (handles sharing, onboarding, access control)

Blocking Claude from Reading Secret Files

Whichever approach you pick above, it’s worth adding one more defensive layer: stopping Claude from reading your secret files in the first place.

These risks are real and getting worse. On March 24, 2026, LiteLLM — the most popular open-source LLM proxy with around 97 million monthly downloads — was found to have credential-stealing malware injected into versions 1.82.7 and 1.82.8 on PyPI. The malicious code harvested SSH keys, cloud credentials, database passwords, and LLM API keys from developers’ machines. It was part of a coordinated campaign by a threat actor called TeamPCP that had already compromised Trivy, Checkmarx, and several npm packages.

Earlier, in March 2025, Pillar Security disclosed a different kind of supply chain attack they called the “Rules File Backdoor.” Attackers hid malicious instructions in AI configuration files using invisible Unicode characters. You clone a repo, your AI assistant reads the config, and it starts quietly exfiltrating your environment variables and API keys. GitHub Copilot and Cursor were both affected.

GitGuardian’s 2026 report found 29 million secrets leaked on public GitHub, with AI-assisted code leaking at roughly double the baseline rate.

Claude Code’s permissions system has deny rules for exactly this.

Permission Deny Rules

Add these to ~/.claude/settings.json so they apply globally — .env files can show up in any project, so it’s worth setting this once and not having to think about it again:

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Edit(./.env)",
      "Edit(./.env.*)"
    ]
  }
}

This blocks Claude’s built-in Read and Edit tools from touching any .env file. Path patterns work like gitignore:

Pattern What it blocks
Read(./.env) .env in project root
Read(./.env.*) .env.local, .env.production, etc.
Read(./secrets/**) Everything in secrets/ recursively
Read(//Users/me/.ssh/**) Absolute path (// prefix)
Read(~/.zshrc) Home directory (~ prefix)

Deny rules get evaluated first. They always win.

The Bash Loophole

One thing to be aware of: deny rules only block Claude’s built-in tools. They won’t stop it from running cat .env through Bash.

You can close that gap with a PreToolUse hook — a script that intercepts tool calls before they execute:

// .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/block-env-bash.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/block-env-bash.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -qE '\.env'; then
  echo "Blocked: Cannot access .env files via Bash" >&2
  exit 2
fi

exit 0

Exit code 2 blocks the action. The error message gets fed back to Claude so it knows what happened and why.

Stack Your Defences

For the strongest protection, use all three together:

  1. Permission deny rules — block Read and Edit
  2. PreToolUse hooks — block Bash commands that touch secret files
  3. Keychain or Doppler — keep secrets out of files entirely

No single layer is bulletproof. Together, they make accidental exposure very hard.

Other Secrets Managers

The Keychain approach from Option 4 uses a shell wrapper — your MCP server command runs a shell script that fetches the secret at startup, sets it as an environment variable, then launches the server. The same pattern works with any secrets manager that has a CLI.

Here’s the general pattern in your .mcp.json:

{
  "mcpServers": {
    "my-server": {
      "command": "/bin/sh",
      "args": ["-c", "MY_SECRET=$(YOUR_CLI_COMMAND) exec node server.js"]
    }
  }
}

Replace YOUR_CLI_COMMAND with the relevant command for your secrets manager:

Manager CLI command Notes
1Password op read 'op://Vault/Item/token' 1Password also offers op run which can inject secrets as env vars directly — see their MCP guide for that approach
Bitwarden bws secret get <secret-id> --output json | jq -r '.value' Requires Bitwarden Secrets Manager (separate from the password vault). The older bw get password command needs an unlocked vault session, making it impractical for automated startup
AWS aws secretsmanager get-secret-value --secret-id my-secret --query SecretString --output text Requires aws configure or IAM role
GCP gcloud secrets versions access latest --secret=my-secret Requires gcloud auth login
Azure az keyvault secret show --vault-name my-vault --name my-secret --query value -o tsv Requires az login
HashiCorp Vault vault kv get -field=token secret/mcp Requires VAULT_TOKEN or prior vault login

Important: All of these (except macOS Keychain) require you to authenticate with the CLI before starting Claude Code. If you haven’t logged in, the MCP server will fail to start. Keychain handles this more seamlessly since it uses your macOS login keychain, which is typically already unlocked when you’re logged into your Mac.

Keychain Tips

If you’re using the macOS Keychain approach from Option 4, here are a few things worth knowing:

Updating a secret: Add the -U flag to overwrite an existing entry:
security add-generic-password -a "cloudflare" -s "api-token" -w "new-token" -U

Skipping confirmation dialogs: Grant /usr/bin/security access so it doesn’t prompt you each time:
security add-generic-password -a "cloudflare" -s "api-token" -w "token" -T /usr/bin/security

Performance: Keychain lookup adds roughly 30ms at MCP server startup — you won’t notice it.

References

Is your website not up to scratch?

Hire our team to take care of it and get back to focusing on what really matters to you.