Where to Store MCP Server API Keys in Claude Code Without Committing Them

Short answer: do not put the key itself in .mcp.json. If the server supports OAuth, sign in with /mcp and the file holds only a URL. Otherwise commit a .mcp.json that contains ${VAR} references, and supply the values through your environment or a secret manager. The commands claude mcp add --env, --header and add-json write whatever you type to disk, and claude mcp get prints it back, so a literal key passed to them is a literal key in a file.

This guide is based on the Claude Code documentation, opened on October 22, 2026, and on a test of version 2.1.307 on Linux that used claude mcp commands with fake keys. No model session was run, because the CLI was not logged in; servers were exercised through the health check that claude mcp list runs, so everything below is about configuration and what the CLI writes or prints. Observed results come from one run each and may differ on your version. The changelog records several changes to how claude mcp displays credentials since September, so check yours with claude --version.

Where does Claude Code keep MCP server settings?

Short answer: in one of three places, chosen by scope. Only the project scope is shared through git.

Scope Stored in Shared with the team
local (default) ~/.claude.json, under the entry for your project path No
project .mcp.json in the project root Yes, if you commit it
user ~/.claude.json, top level No

The docs recommend local scope for "servers with credentials you don't want in version control". Two details trip people up. MCP's local scope lives in ~/.claude.json, not in .claude/settings.local.json. And precedence is by whole entry: local beats project beats user, and "fields are not merged across scopes". A local server with the same name as a project one replaces it entirely, including its env and headers.

On managed machines there is also managed-mcp.json (for example /etc/claude-code/ on Linux). The docs warn that any user on the machine can read it, so it should not hold keys in env blocks.

What do claude mcp add and claude mcp get do with a key?

Short answer: add writes the literal value to the scope's file, and get and list print literal values. Only ${VAR} references stay masked.

Observed on version 2.1.307, with values like sk-test-FAKE-123:

  • claude mcp add name --transport stdio -e API_KEY=... -- command, the same command with --header "Authorization: Bearer ..." for a remote server, and claude mcp add-json stored the value in plaintext. With --scope project, it went into .mcp.json, which was created with mode 644, so it is readable by other users and ready for git add. ~/.claude.json, which holds user and local scope, was mode 600.
  • After add --header, the command's own output showed "Authorization": "[REDACTED]". The file still held the full value.
  • claude mcp get printed header values, env values, args and URL query strings as stored. This included a project server that was still waiting for approval. claude mcp list printed the command line and URL, including --api-key ... and ?key=... parts.
  • A ${VAR} reference was shown by name, not value, and the docs say the same.

The changelog for version 2.1.161 says credential headers and URL secrets are redacted in these commands, and later releases changed what is displayed. That did not match what was observed for get with these fake values, so the redaction may depend on the shape of the value. Treat get and list output as sensitive when an entry holds literals, and do not paste it into issues or chats.

Which method should you use?

Short answer: pick the first row that your server supports.

Method Use when What lands in the file Main caveat
OAuth (/mcp or claude mcp login <name>) The remote server supports OAuth Type and URL only The docs say tokens are "stored securely" but do not say where
${VAR} reference Any stdio server, or a remote server with a static token The variable name Values must come from your environment; unset variables are not an error
headersHelper Remote server that needs short-lived or computed headers The path of a script Runs for up to 10 seconds; project-scope helpers lose credential-looking variables
Wrapper command The stdio server needs secrets from a manager The wrapper's command line Not covered by Claude Code docs; verify it in your setup

Also, if a configured Authorization header is rejected, there is no OAuth fallback. The docs say the connection is reported as failed instead of falling back, and claude mcp list printed: "OAuth fallback is disabled when headers.Authorization is set."

How do you write a .mcp.json that holds no keys?

Short answer: reference variables by name in command, args, env, url and headers. Claude Code expands ${VAR} and ${VAR:-default} in those fields.

{
  "mcpServers": {
    "example-local": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "example-mcp-server"],
      "env": {
        "EXAMPLE_API_KEY": "${EXAMPLE_API_KEY}"
      }
    },
    "example-remote": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headers": {
        "Authorization": "Bearer ${EXAMPLE_MCP_TOKEN}"
      }
    }
  }
}

example-mcp-server and mcp.example.com are placeholders. Edit the file by hand or add it with claude mcp add-json --scope project, which stored ${...} references unchanged in testing. Do not use add -e KEY=... for this, because it takes the literal.

Export the variables in the shell that starts Claude Code, or load them from a secret manager (see the last section). Three behaviors matter before you rely on this.

An unset variable is not an error. Per the docs, the config still loads and Claude Code "uses the unexpanded ${VAR} text as-is". In testing, the stdio server received the string ${UNSET_VAR_XYZ} as its key. The only hint is a "Missing environment variables" line in claude mcp list and a warning in /mcp; claude mcp get output looked the same whether the variable was set or not. A typo in a variable name therefore tends to show up as a rejected request, such as a 401, and not as a startup failure.

Credential-named variables read as empty in remote url and headers. The docs: "A covered name reads as empty whether or not you have set the variable". Named examples are ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, AWS_BEARER_TOKEN_BEDROCK, HTTPS_PROXY and NPM_TOKEN; the full list is not published. In testing, Bearer ${ANTHROPIC_AUTH_TOKEN} arrived as Bearer with nothing after it, and a header built from NPM_TOKEN was empty, while a variable named MY_TOKEN_Z expanded normally. The docs' fix is to copy the value into a variable with your own name and reference that. This rule applies to remote URLs and headers. A stdio env value with an ordinary variable name expanded normally in the same test, and a covered name in stdio env was not tried.

A local entry replaces a project one. If you once ran claude mcp add with a literal key at local scope under the same server name, that entry wins over the committed file, and the literal stays in ~/.claude.json. Per the docs, Claude Code warns in claude mcp list and /mcp only when the two entries point to different endpoints. Check ~/.claude.json for a local entry with the same name as a committed server.

Which environment does a stdio MCP server inherit?

Short answer: by default, all of it. A stdio server started by Claude Code saw every fake secret exported in the parent shell during testing: ANTHROPIC_API_KEY, AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, DB_PASSWORD and NPM_TOKEN.

Two settings narrow this, and both are documented in the environment variables reference rather than on the MCP page:

  • CLAUDE_CODE_MCP_ALLOWLIST_ENV=1 starts stdio servers with "only a safe baseline environment plus the server's configured env". In testing, none of the fake secrets exported in the shell arrived, while the entry's own env (including expanded ${VAR} values) did. As a result, a variable you want the server to have must now be listed in its env block.
  • CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 removes variables that look like credentials from what Bash commands, hooks and stdio MCP servers receive. Detection uses the variable name and the value shape, so a secret whose name and value both look ordinary passes through, and the docs say GitHub tokens and proxy settings are kept. On Linux the option needs bubblewrap, so its effect on an MCP server was not observed here. Our guide to keeping .env secrets away from Claude Code covers it in more detail.

The env block in settings.json is also plain text. The settings reference says its values "reach every subprocess Claude Code starts", so it is not a place for keys either.

When should you use headersHelper?

Short answer: for a remote server whose token must be fetched or refreshed, because the script runs on every connection and nothing is cached.

Add "headersHelper": "/path/to/script" to the server entry. The script prints a JSON object of string pairs to stdout. These headers override static headers with the same name. Observed behavior and documented limits:

  • The helper has 10 seconds. A helper that slept for 15 seconds made claude mcp list take about 10.8 seconds and no helper header was sent.
  • For project and local servers, the helper does not run until the workspace trust dialog has been accepted. Without that, claude mcp list reported "headersHelper not run". A user-scope helper ran from ~/.claude.
  • Helpers from project scope run without credential-looking variables (names containing TOKEN, SECRET, PASSWORD, KEY or AUTH per the docs). The script must read its secret from a file or a credential store, not from the environment. In testing, a project helper did not see a custom variable called MY_TOKEN_Z; a local-scope helper did.
  • An Authorization header from the helper turns off the OAuth fallback, like a static one.

What changes when a team shares the repository?

Short answer: committed servers need approval, but not in every mode, and a committed file is only as safe as what is inside it.

In an interactive session, Claude Code asks before using project servers; claude mcp reset-project-choices resets the answers. The docs state the limit: "Claude Code can't show that prompt: it loads project-scoped servers without asking." That covers claude -p, the Agent SDK and some cloud sessions. To opt out there, use disabledMcpjsonServers, --setting-sources or --strict-mcp-config. Approvals committed in .claude/settings.json are ignored in a folder that is not yet trusted, so a cloned repository cannot approve its own servers.

For an organization, allowedMcpServers and deniedMcpServers (deny wins) and allowManagedMcpServersOnly control which servers may run, and a permission rule such as mcp__<server>__* can deny a server's tools. These limit what runs. They do not protect a key that is already written in a file.

How do you check which keys are still on disk?

Short answer: search the places where literals end up, then confirm that references expand.

  1. In the repository: git grep -n -E 'sk-|Bearer [A-Za-z0-9]|key=|api-key' -- .mcp.json .claude. This searches tracked files in the working tree only, not history, and the pattern is an example: adjust it to your providers' key prefixes.
  2. In your home config: open ~/.claude.json and look at mcpServers and projects.<your path>.mcpServers for literal values in env, headers, args and url.
  3. Run claude mcp list. A line "Missing environment variables" under the config diagnostics means a reference has no value in this shell.
  4. For a remote server, a 401 right after you changed a variable name is the symptom of an unset or blanked reference. Compare with the credential-name rule above.
  5. After each Claude Code upgrade, repeat steps 2 and 3, since display rules have been changing.

What if a key is already committed?

Short answer: revoke it at the provider first. Deleting the line from .mcp.json does not change the commit history, and anyone who fetched the repository still has the key.

After revoking, issue a new key, put it behind a ${VAR} reference, and then clean the file and, if you choose, the history. The order of actions and the provider consoles are in our guide to what to do when an OpenAI or Anthropic key leaks. If a key was only printed by claude mcp get in a terminal or a shared screen, treat it as exposed to whoever saw it.

Where should the values come from?

Short answer: from a secret manager that puts them in the environment of the process that needs them, so no dotfile holds them.

The MCP authorization specification says a stdio server "SHOULD NOT follow this specification, and instead retrieve credentials from the environment". Environment variables are the expected channel, and ${VAR} references are how Claude Code hands them over. What remains is how the variables get into your shell without a plaintext .env or a shell profile line.

One option is a CLI that runs a command with secrets injected. Ennote, the company behind this blog, makes one, and its CLI documents ennote secret get <secret-name> -- <command>, which injects a secret's keys as environment variables of the child process, and ENNOTE_TOKEN for machine identities in headless use. Wrapping a stdio server in it would look like claude mcp add example-local --transport stdio -- ennote secret get example-keys -- npx -y example-mcp-server. That combination was not run for this article. Two things to check if you try it: with CLAUDE_CODE_MCP_ALLOWLIST_ENV=1 the wrapper itself needs its own credentials in the entry's env, which brings back a secret in a file, and the environment scrub may remove a variable named like a token. The Ennote docs do not mention MCP, so confirm both in your setup.

For remote servers the same idea applies one level up: start Claude Code from a shell where the secret manager has already exported the variables your ${VAR} references expect.

Sources


Keep keys out of dotfiles. Ennote is a secret manager for passwords, API keys and environment variables, with role-based access and an audit log. Start for free on the Developer plan.