Development Choices

Secrets exposed in MCP client config files

Author
Joseph Trasatti Member of technical staff
Published
Section
MCP
Length
8 min read3 sources cited

An MCP client config that authenticates with headers stores a plaintext API secret in a project or user-profile file that nothing protects by default. Project-scoped copies get committed because they look like configuration, not credentials. Use OAuth wherever a browser is reachable, and recover by rotating at the vendor rather than deleting the file.

Start here: what is actually on disk

An MCP client config is a small JSON file describing which servers to connect to. When a server authenticates with headers, that file is also the only thing standing between a live API secret and anyone — or any process — that can read the directory it sits in. The cycles below are ordered by how often each one actually bites.

Auth modeWhat the config holdsWhere it livesLifetime
Header authThe API secret in clear textProject directory or user profileUntil you rotate it
Packed URL credentialSecret inside a single URL-shaped stringUsually a .env beside the projectUntil you rotate it
OAuth (remote server)A server URL and nothing elseClient’s own credential storeToken expires

Symptom: the server works, and its secret is readable in plain text

Likely cause. Header authentication has no other form. The MediaFlows MCP server authenticates with cld-cloud-name, cld-api-key and cld-secret headers, and those values have to reach the client somehow — so they sit in a config file, unencrypted, in a project directory or a user profile. Neither location is protected by default. There is no keychain entry, no separate unlock, no permission prompt: it is an ordinary file with ordinary permissions, and on a shared or synced machine that means ordinary readability. The same applies to any local server, which needs manual credentials because there is no browser round-trip to replace them. Practical details of writing that file are in configuring the MediaFlows MCP server.

Check. Print the file and look at it — if you can cat the secret, so can anything running as you. Then check the mode: ls -l on macOS and Linux, icacls <path> on Windows. A config created by an editor or an installer is typically world-readable, not 0600. Then ask a second question that people skip: is that path inside a directory synced to a cloud backup, and does any agent you run have file-read access to it? Sensitive information disclosure is one of the categories in OWASP’s Top 10 for LLM applications, and an agent with repository read access reaches a project-scoped config without doing anything unusual.

Fix. Tighten the mode to owner-only (chmod 600), and move header-authenticated servers out of project scope into the user-profile config, which at least is not sitting inside a repository. If your client supports environment-variable expansion in the config, use it — but be honest about what that buys: the secret moves to a file you already treat as sensitive, it does not stop existing. Header auth always ends with a plaintext secret somewhere on that disk.

Symptom: the secret turns up in git history, weeks later

Likely cause. The project-scoped config is the copy that gets committed, and it gets committed for two compounding reasons. First, it lives beside files that are meant to be committed — it looks like tooling configuration, because it mostly is, and git add . does not distinguish. Second, it does not match the usual secret-scanning patterns. Scanners are keyed on filenames like .env and on value prefixes like AKIA or sk-; a header value under a headers key is an opaque string with no prefix, in a .json file nobody flagged. The commit passes the hook, passes review, and the secret is in history.

Check. Three commands, in increasing cost:

Then verify the second half of the cause rather than assuming it: run your own secret scanner against the config file as it stands and see whether it flags anything. If it stays silent, you have just measured the gap.

Fix. Add the project-scoped config path to .gitignore and confirm with git check-ignore -v <path>. Add a scanner rule keyed on the header namescld-secret and its equivalents for other vendors — rather than on a value pattern, because the header name is the only stable, greppable thing in that file. Wire that rule into a pre-commit hook so the check runs where it can still prevent the commit. And if any of the checks above came back positive, stop tuning the scanner and go to the rotation section: the file is already out.

Symptom: the secret appears in a log, a stack trace, or your shell history

Likely cause. The credential was in URL-shaped form. npx @cloudinary/cloud provisions a working product environment and writes CLOUDINARY_URL to ./.env — one string carrying the key and secret inside a URL. That packing is exactly what makes it escape. A single string can be echoed, exported inline, interpolated into an error message that reports the connection target, and pasted into an issue by someone who thinks they are sharing a hostname. Discrete header fields have to be reassembled before they leak; a packed URL leaks as a unit. Redaction rules keyed on the variable name also miss it, because once the value is inside a URL in prose, the variable name is gone. The broader question of where a freshly issued credential should land is covered in where an agent stores issued credentials.

Check. Grep the places it escapes to, in this order:

Fix. Never pass the packed string on a command line; have the process read it from a file. Register it as a masked variable in CI so the provider redacts it in logs. Where a server accepts discrete header fields as well as a packed URL, prefer the fields. One mitigation is specific to the provisioning command and worth knowing: an unclaimed cloud from npx @cloudinary/cloud expires after 24 hours, and delivery is locked to the public IP the command ran from unless you add others with --ip. That bounds the damage from a leaked unclaimed environment. It does nothing once the environment is claimed, so do not treat it as protection.

Symptom: every fix above narrows the exposure and none of them removes it

Likely cause. You are still solving the problem of a file with a secret in it. OAuth removes the file from the problem entirely — the config holds a server URL, the token lives in the client’s own credential store, and it expires on its own. There is no long-lived secret on disk to permission, gitignore, scan for, or grep out of a log, which is the strongest argument for OAuth anywhere a browser is reachable. Cloudinary’s remote Asset Management, Environment Config and Structured Metadata servers use OAuth; local servers need manual credentials.

Check. Go through your config server by server and answer two questions per entry. Does a remote OAuth endpoint exist for it — for Cloudinary those are https://asset-management.mcp.cloudinary.com/mcp, https://environment-config.mcp.cloudinary.com/mcp and https://structured-metadata.mcp.cloudinary.com/mcp. And can the machine running the client open a browser? A headless container, a CI runner or an SSH session cannot complete the flow, and that condition, not preference, is what decides the answer.

Fix. Move every server that has a remote OAuth endpoint and runs where a browser is reachable. For the rest — CI, headless boxes, and servers with no OAuth surface, MediaFlows included — you are on header auth by necessity and the file discipline above is the containment, not a second-best you should feel bad about. The full trade is in OAuth versus API key headers, and the deployment side in remote versus local MCP servers.

Symptom: you deleted the file, and you are not actually recovered

Likely cause. Deleting a file removes your access to the secret, not everyone else’s. Anything committed once has to be assumed read: clones, forks, CI caches, backups, the agent that read the repository, the colleague who pulled that branch. Removing the file from HEAD leaves it in history, and rewriting history does not reach copies that already left your machine. The only action that changes the security state is invalidating the credential at the vendor, which is why OWASP’s secrets management guidance treats rotation as the remediation and cleanup as hygiene.

Check. Before rotating, list every place the current credential is configured — your machine, teammates’ machines, CI variables, container images, any product environment configured for a preview deployment — or you will rotate and take down a build you forgot about. After rotating, prove the old one is dead by making a request with it and confirming the rejection. An assumed-revoked key is not a revoked key.

Fix. Rotate at the vendor first, reconfigure the clients second, and treat scrubbing git history as optional cleanup that happens afterwards and changes nothing about your exposure. If the leaked credential was the packed URL form, rotating the key invalidates the whole string, so there is no partial recovery to reason about — which is the one advantage that form has.

Sources

  1. MediaFlows MCP server authenticates with `cld-cloud-name`, `cld-api-key` and `cld-secret` headers cloudinary.com
  2. OWASP's Top 10 for LLM applications owasp.org
  3. OWASP's secrets management guidance cheatsheetseries.owasp.org

See also