# API keys in MCP server configs: where they end up

> MCP clients keep server settings as plain JSON, and the API key usually sits in it. Where those files live, who can read them and how to keep keys out.

URL: https://crunu.com/blog/api-keys-in-mcp-server-configs
Language: en
Updated: 2026-10-30

An MCP server's API key usually ends up in a plain JSON file. Claude Desktop, Claude Code and Cursor all configure servers in JSON, and the quickest way to give a server a key is to type it into that file's `env` or `headers` block. The file then sits in your home directory or in your repository, readable by every program that runs as you, including the coding agent that uses the server, and it travels into backups, dotfile repositories and sometimes Git. The fix has three parts: reference variables instead of writing values, give each server its own narrow key, and start local servers through a launcher that injects the key when the process starts.

The guide to [secrets and AI coding agents](https://crunu.com/guides/secrets-and-ai-coding-agents) covers the wider question of keeping secrets out of an agent's reach. This post is about one file type.

## Where each client keeps its servers

| Client                            | File                                                                                                                                 | Notes                                             |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
| Claude Desktop                    | `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows | one file for all servers                          |
| Claude Code, local and user scope | `~/.claude.json`                                                                                                                     | local scope is stored "under that project's path" |
| Claude Code, project scope        | `.mcp.json` in the project root                                                                                                      | made to be committed                              |
| Cursor                            | `.cursor/mcp.json` in a project, `~/.cursor/mcp.json` globally                                                                       | supports `envFile` for local servers              |

The paths come from each product's documentation ([MCP: connect to local servers](https://modelcontextprotocol.io/docs/develop/connect-local-servers), [Claude Code: MCP](https://code.claude.com/docs/en/mcp), [Cursor: MCP](https://cursor.com/docs/context/mcp)).

One row deserves attention. Claude Code's documentation says of project scope: "Check `.mcp.json` into version control so everyone on your team gets the same MCP tools and services." That is a good way to share tools and a bad place for a literal key. Anyone who adds a server with `--scope project` and pastes a token into its `env` block has put that token into the repository.

## What a typical entry looks like

A local server is a program the client starts and talks to over standard input and output. Its configuration names the command and, often, the key:

```json
{
  "mcpServers": {
    "issues": {
      "command": "npx",
      "args": ["-y", "@example/issues-mcp-server"],
      "env": {
        "ISSUES_API_KEY": "EXAMPLE_not_a_real_key"
      }
    }
  }
}
```

A remote server is an address, and the key goes into a header:

```json
{
  "mcpServers": {
    "tickets": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer EXAMPLE_not_a_real_token" }
    }
  }
}
```

Both are valid, and both store the value in clear text next to dozens of other settings.

## Who can read that file

- **Every process that runs as you.** The file has your user's permissions. A script, an editor extension or a package's install hook can open it.
- **The coding agent.** An agent with a file-reading tool or a shell can read `~/.claude.json` or `~/.cursor/mcp.json` as easily as a `.env`. Whether it does depends on its permission rules, which [do AI coding agents read your .env files?](https://crunu.com/blog/do-ai-coding-agents-read-your-env) walks through, and on what it's been told by content it read; see [prompt injection meets your environment variables](https://crunu.com/blog/prompt-injection-and-environment-variables).
- **Copies.** Home-directory backups, a dotfiles repository, a synced settings folder or a support bundle sent to a vendor all carry the file with them.
- **Logs.** Claude Desktop writes each local server's standard error to `mcp-server-SERVERNAME.log`. A server that prints its configuration on startup puts the key there too.

The MCP documentation is direct about the program itself: a local server "runs with your user account permissions, so it can perform any file operations you can perform manually." A server you install from a package registry is code with your key and your file system.

*Figure: Diagram of where a secret goes once it is in a laptop. Backups and synced folders: copies outside the laptop; it stays after you delete it. Copies sent from it: chat pastes and email attachments; it stays after you delete it. Shell history: if it was typed or exported in a terminal; it stays after you delete it. Tools that read the project folder: editor extensions and AI coding agents. A lost or stolen laptop: if the disk isn’t encrypted. Deleting the copy doesn’t undo the leak, so the value has to be reissued at its provider.*

## Step 1: reference variables, not values

Both Claude Code and Cursor expand variables in the configuration, so the file can name a key without holding it.

Claude Code reads `${VAR}` and `${VAR:-default}` in a server's `command`, `args`, `env`, `url` and `headers`. Its documentation presents this as the way to share `.mcp.json` "while maintaining flexibility for machine-specific paths and sensitive values like API keys":

```json
{
  "mcpServers": {
    "tickets": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer ${TICKETS_API_KEY}" }
    }
  }
}
```

One detail is worth knowing: in a remote server's `url` and `headers`, Claude Code reads certain credential variables, such as `ANTHROPIC_API_KEY` and `NPM_TOKEN`, as empty, so a committed config can't send them to a server it names. Use a variable name of your own for each server's key.

Cursor uses `${env:NAME}` in the same fields, and its MCP page recommends to "use environment variables for secrets, never hardcode them." Cursor also accepts `envFile` for local servers. That moves the key out of the JSON, but into a `.env` file, which is still a plaintext file on disk that the agent can open.

Variables only move the problem to wherever the variable is set. If that is an `export` line in `~/.zshrc`, the key is in a different plaintext file, and now in every program your shell starts.

## Step 2: one narrow key per server

Give each MCP server its own key, created for that server alone. It's [least privilege](https://crunu.com/learn/least-privilege) applied to tools:

- **Scope it.** A server that reads issues needs a token that reads issues. GitHub's fine-grained tokens, OpenAI project keys with restricted permissions and Anthropic keys scoped to one workspace all allow that; the [OpenAI](https://crunu.com/rotate/openai), [Anthropic](https://crunu.com/rotate/anthropic) and [GitHub](https://crunu.com/rotate/github) rotation pages describe what each key covers.
- **Cap what it can spend.** Where the provider offers per-project or per-workspace limits, put the agent's key in its own project with its own limit, so a runaway loop or a stolen key has a ceiling.
- **Name it after the server and the machine.** When you need to revoke it, you'll know which one to revoke, and nothing else breaks.

A separate key doesn't keep the value secret. It makes the damage smaller and the cleanup faster.

## Step 3: start the server through `crunu run`

For local servers, the key can stay out of every file. The client starts whatever `command` says, so the command can be a launcher that fetches the variables and then starts the real server. With Crunu, that launcher is `crunu run`:

```json
{
  "mcpServers": {
    "issues": {
      "command": "crunu",
      "args": [
        "run", "--env", "agent-tools/local", "--",
        "npx", "-y", "@example/issues-mcp-server"
      ]
    }
  }
}
```

Here `agent-tools/local` is an example environment holding `ISSUES_API_KEY`, and the package name is a placeholder for the server you use. Before starting the client, sign in once with `crunu login` and keep your keys in memory with `crunu unlock --ttl 2h` (up to 12 hours). `crunu run` decrypts the environment, starts the server with a clean environment (`PATH`, `HOME` and a few others) plus the environment's variables, and passes standard input and output through, so the client talks to the server as before. Its own messages go to standard error, and the values never appear in them. The config file contains a project name, not a key.

*Figure: Example: on the server api-prod-1, crunu status shows a healthy machine holding version 15 of payments-api production with a lease until tomorrow. crunu run starts node server.js with that environment’s variables, which exist only in process memory; the web card shows the machine Active with the latest version.*

What this changes and what it doesn't:

- **No key on disk.** The JSON, the repository, the backups and the dotfiles hold nothing worth stealing.
- **Locked means off.** After `crunu lock` or when the time runs out, the server can't start. You decide when the agent's tools have credentials.
- **The process still has the key.** The server needs it to call the API, so it sits in that process's environment while it runs. Other code running as you can often read a process's environment, for example through `/proc` on Linux, and the server can still log what it receives.
- **Platforms.** The `crunu` command line runs on Linux and macOS. There is no Windows build.

## Remote servers and OAuth

For HTTP servers, prefer OAuth when the server supports it. Claude Code's documentation says that for OAuth the client secret "is stored securely in your system keychain (macOS) or a credentials file, not in your config." A token in the system keychain is still readable by you, but it isn't in a JSON file that gets committed, synced or pasted into a bug report.

## If a config file leaked

Treat it like a leaked `.env`:

1. List every key in the file, including old entries for servers you no longer use.
2. Reissue each one at its provider, then revoke the old one. Reissue first, because copies may already exist.
3. If it was `.mcp.json`, check Git history and every fork; deleting the line in a new commit doesn't remove it from history. [A .env file leaked into Git](https://crunu.com/blog/env-file-leaked-in-git) covers the order of the cleanup.
4. Check dotfile repositories and backups that include the home directory.

The glossary entry on [MCP servers](https://crunu.com/learn/mcp-server) has the short definition.

## Checklist

- [ ] No literal keys in `.mcp.json`, `~/.claude.json`, `~/.cursor/mcp.json` or `claude_desktop_config.json`
- [ ] Variables use names of your own, one per server
- [ ] One key per MCP server, scoped to what that server does, with a spending limit where available
- [ ] Local servers start through a launcher such as `crunu run`
- [ ] OAuth for remote servers that support it
- [ ] Config files excluded from dotfile repositories

## Sources

- [Connect to local MCP servers, Model Context Protocol](https://modelcontextprotocol.io/docs/develop/connect-local-servers), checked October 30, 2026.
- [Connect Claude Code to tools via MCP, Claude Code Docs](https://code.claude.com/docs/en/mcp), checked October 30, 2026.
- [Model Context Protocol (MCP), Cursor Docs](https://cursor.com/docs/context/mcp), checked October 30, 2026.
