Where should MCP server API keys live?
By Maria Otworowska,
Most MCP setup guides tell you to paste a token into the env block of a JSON file. That works, and it leaves a plain-text key in a file that tools and agents read, and that sometimes gets committed.
What the spec says
The MCP authorization spec says local servers that talk over stdio should not use its OAuth flow and should instead "retrieve credentials from the environment". Popular servers follow that: the GitHub MCP server reads GITHUB_PERSONAL_ACCESS_TOKEN, and Notion's reads NOTION_TOKEN.
Neither the spec nor its security best practices page says where those environment values should come from. Each client answers that differently.
How each client handles it
Claude Code
claude mcp add --env KEY=value sets a variable for the server. Servers you add at the default local scope, or at user scope, are stored in ~/.claude.json, which the MCP docs describe as the place for "servers with credentials you don't want in version control".
Project-scoped servers go in .mcp.json, and the docs say to check that file into version control. For keys, it supports ${VAR} expansion so the committed file holds a placeholder:
{
"mcpServers": {
"github": {
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" }
}
}
}Two things to know. ${GITHUB_TOKEN} is read from Claude Code's own environment, so the key still has to be exported somewhere, usually your shell profile, where every other program gets it too. And if the variable isn't set, the docs say Claude Code uses the unexpanded ${VAR} text as-is, so the server starts with a literal placeholder as its token and fails in confusing ways.
Cursor
Cursor reads .cursor/mcp.json in the project and ~/.cursor/mcp.json globally. Its MCP docs support ${env:API_KEY} interpolation and an envFile option that loads a .env for stdio servers, with the advice to "use environment variables for secrets, never hardcode them". An envFile is still a plain-text file in the project.
Claude Desktop
Claude Desktop reads ~/Library/Application Support/Claude/claude_desktop_config.json, and the official example puts the API key straight into the env block. That's a plain-text key in a JSON file in your Library folder. Desktop Extensions are the exception: fields an extension marks as sensitive are kept in the OS keychain.
Codex
Codex configures servers under mcp_servers in ~/.codex/config.toml, with an env table for values and an env_vars list for variables to forward from its own environment. Same choice as the others: the value is either in the file or in your shell.
Keep the key out of the config entirely
A manual env block keeps the key in a config file, and ${VAR} interpolation reads it from the client's environment, which usually means your shell profile. Claude Desktop Extensions already use the OS keychain, but only for extensions. For everything else there's another way: let the config start the server through a command that adds the keys at launch.
With fidelius, our Mac app that keeps API keys by project in your iCloud Keychain, the GitHub server from the example above becomes:
{
"mcpServers": {
"github": {
"command": "/Users/you/.local/bin/accio",
"args": ["myapp", "--no-mask", "--",
"/opt/homebrew/bin/docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"]
}
}
}accio looks up the keys in project myapp, puts them into the server's environment and starts it. The config holds no key and no placeholder, and nothing needs exporting from your shell profile. The same shape works in Cursor's mcp.json and in Claude Desktop's config.
The variable name has to match what the server reads. GitHub's server reads GITHUB_PERSONAL_ACCESS_TOKEN. If your project calls it GITHUB_TOKEN, the server still starts and lists its tools, but every GitHub call fails. Either add the key under the server's name, or map it in a small shell wrapper:
"args": ["myapp", "--no-mask", "--", "/bin/sh", "-c",
"GITHUB_PERSONAL_ACCESS_TOKEN="$GITHUB_TOKEN" exec /opt/homebrew/bin/docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server"]We tested both configs on October 4, 2026, with Claude Code as the client and with a bare environment like Claude Desktop's: the server started, listed its tools and answered an authenticated get_me call.
1Password users can do the same with op run --env-file=/absolute/path/mcp.env.op -- <server command>, where the file maps GITHUB_PERSONAL_ACCESS_TOKEN to an op:// reference. Check that its output masking doesn't touch the JSON stream. The principle is what matters: the file that describes your servers shouldn't be the file that holds their keys.
Checklist
- Search your MCP configs for real tokens: .mcp.json, ~/.claude.json, ~/.cursor/mcp.json, claude_desktop_config.json, ~/.codex/config.toml.
- Move each key into an encrypted store and start the server through a command that injects it.
- If a config with a real key was ever committed or shared, rotate that key.
- Give MCP servers the narrowest token that works, such as a fine-grained GitHub token limited to the repos it needs.
Related: How to keep secrets out of Claude Code, Codex and Gemini CLI at the same time and Can Claude Code read your .env file?.