How to handle secrets in MCP servers safely
Most MCP servers need a secret of some kind: an API key for the service they wrap, a database password, a signing key. The fastest way to get one working is to paste the key straight into a config file or hard-code it next to the fetch call. That is also the fastest way to leak it. Config files get committed, copied into chat, and synced to backups. This guide covers where secrets should actually live, how to keep them out of URLs and logs, and how to lock down the files that hold them.
Stop pasting keys into client config
When you register a stdio server with Claude Desktop or Cursor, the client config lets you set environment variables for the process. That is the right place to inject a secret, because the value is passed to your server at launch and never has to live in your source tree. Reference an environment variable on the host, do not inline the literal key.
{
"mcpServers": {
"billing": {
"command": "node",
"args": ["/opt/billing-mcp/server.js"],
"env": { "STRIPE_API_KEY": "${STRIPE_API_KEY}" }
}
}
}
The catch: this client config file itself often sits in a synced or backed-up location
(~/Library/Application Support/Claude/ on macOS, for example). If you inline the real key
there, it rides along to every backup. Keep the literal value in your shell or a secrets manager and
let the config reference it.
Read secrets from the environment, never from code
Inside the server, pull each secret from the environment and fail loudly if it is missing. A server that silently starts with no key will hit a confusing 401 later; one that exits at boot tells you exactly what is wrong.
const apiKey = process.env.STRIPE_API_KEY;
if (!apiKey) {
console.error("STRIPE_API_KEY is not set");
process.exit(1);
}
For local development, a .env file loaded by something like dotenv is fine,
but it must be git-ignored. Add it before you write a single line that reads it:
echo ".env" >> .gitignore
Keep secrets out of URLs and query strings
A surprising number of leaks come from putting a token in a URL instead of a header. URLs are not
private. They land in server access logs, proxy logs, browser history, and the Referer
header sent to the next site. If your MCP tool calls an upstream API, send the secret in an
Authorization header, not as ?api_key=....
// avoid
await fetch(`https://api.example.com/v1/data?api_key=${apiKey}`);
// prefer
await fetch("https://api.example.com/v1/data", {
headers: { Authorization: `Bearer ${apiKey}` }
});
- Never accept a secret as a tool input argument. Tool arguments are model-visible and can end up in transcripts and traces.
- Do not embed tokens in resource URIs your server returns. The model and the client both see those.
- If an upstream genuinely requires a key in the query string, treat its logs as compromised and rotate often.
Lock down file permissions
Any file that holds a secret (a .env, a service-account JSON, a private key) should be
readable only by the user that runs the server. On a shared box, world-readable secrets are a one-line
exfiltration. Set the mode explicitly and check it.
chmod 600 .env ls -l .env # -rw------- 1 you you 142 .env
The same applies to the directory: chmod 700 the folder so another user cannot list or
traverse into it. If you write secrets at runtime, set the mode at creation time
(fs.writeFile(path, data, { mode: 0o600 })) rather than relying on a later
chmod that might never run.
Never log a secret
Logging is where secrets quietly escape. A debug line that dumps the request config, an error handler that prints the full upstream response, or a crash that serializes the whole environment will all spill keys into log files and observability tools that a dozen people can read. Be deliberate about what you print.
- Never log
process.envor a full request object. Log the keys you need, not the whole bag. - Redact tokens before they reach a logger. A short helper such as
mask(key)that shows only the last four characters is enough to debug without exposing the value. - Strip the
Authorizationheader before logging a failed request. - Turn off verbose or wire-level HTTP logging in production, where it tends to print headers in full.
const mask = (s) => s ? `***${s.slice(-4)}` : "(unset)";
console.log("auth ok for key", mask(apiKey));
Catch the mistakes before you ship
These rules are easy to state and easy to forget under deadline. A plaintext key slips into a committed
config, a .env goes out world-readable, a token sneaks into a sample URL. The free
mcp-audit tool scans an MCP server
project for exactly these patterns, including plaintext secrets in config and code, so a leak shows up
in review instead of in someone else's logs. Run it in CI and on every config you hand to a client.
Ship secrets the safe way by default
MCP Forge Kit comes wired for secrets done right: environment-based config, git-ignored env files, header-based upstream auth, redacted logging, and a CI step that runs mcp-audit so a leaked key never makes it past review.
Get MCP Forge Kit, €39Related: Authentication · Deploy securely · Security checklist