Skip to main content

MCP-004 Inline credential in env block

MCP host configs frequently inline live API keys (OPENAI_API_KEY: sk-..., GITHUB_TOKEN: ghp_...) in a server's env block rather than referencing a shell variable (${VAR}). The correct shape is "the host resolves the value from the OS env at launch"; the wrong shape is "the value lives in this JSON file forever, alongside your other dotfiles, in your machine's backup, and in the directory you sync to a cloud service".

What Vulkro detects

Rule fires when a server's env map contains an entry where every gate passes:

  1. The key contains a credential-name lemma (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, STRIPE_API_KEY, SLACK_BOT_TOKEN, etc.; the substring catalog is shared with the secrets detector).
  2. The value is not empty.
  3. The value is NOT a ${VAR} / $VAR env-indirection reference.
  4. Optionally: the value matches a provider-format regex (AWS, Stripe, GitHub, Slack, PEM private key, JWT, etc.).

Severity stratification:

  • Critical: name catalog hit AND provider-format match. The value is a literal production credential.
  • High: name catalog hit AND value length >= 16 (plausible custom token without a well-known prefix).
  • Medium: name catalog hit only, short or opaque value.

Confidence: High when the provider-format regex hit, Medium otherwise.

Evidence signals: mcp-env-secret-name-match (weight 0.5, Pattern) always, plus mcp-env-secret-provider-format-match (weight 0.7, Pattern) when the value matches a known provider format.

Privacy guarantee: Vulkro never embeds the secret value in the finding message or evidence detail. The detail reports the key name and a coarse length bucket (<8, 8-15, 16-23, 24-31, 32-39, 40-47, 48-63, 64-127, 128+) only. Single-digit length precision would itself fingerprint the value; the bucketing is deliberate. The framing is "you committed this", not "we saw this".

Non-compliant config

{
"mcpServers": {
"github": {
"command": "uvx",
"args": ["mcp-server-github==0.4.2"],
"env": {
"GITHUB_TOKEN": "ghp_3rL9aXk2qNvT8WzC0xY1fE6sBpH4dM7uV5jK"
}
},
"stripe": {
"command": "npx",
"args": ["@modelcontextprotocol/[email protected]"],
"env": {
"STRIPE_API_KEY": "sk_live_51N8t7K2eZvB4cQfL9xA0pY3uT6wM8d"
}
}
}
}

Both values are literal production credentials sitting in a JSON file under ~/.cursor/ or ~/Library/Application Support/Claude/.

Compliant config

{
"mcpServers": {
"github": {
"command": "uvx",
"args": ["mcp-server-github==0.4.2"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
},
"stripe": {
"command": "npx",
"args": ["@modelcontextprotocol/[email protected]"],
"env": {
"STRIPE_API_KEY": "${STRIPE_API_KEY}"
}
}
}
}

The shell sets GITHUB_TOKEN and STRIPE_API_KEY (in ~/.zshrc, ~/.bashrc, a per-project direnv file, or a platform secrets manager); the host process reads them from the OS env at launch.

Remediation

  1. Replace the inline literal with a ${VAR} reference. The MCP host resolves the variable from the process environment at launch, so the JSON file no longer carries the secret.
  2. Set the variable somewhere appropriate: a shell rc file for personal credentials, a direnv config for per-project scoping, your OS keychain or a platform secrets manager for shared workstations.
  3. Rotate the credential if it was a real one. The literal now exists in the host config file's git history (if the file is tracked) and in every backup the machine has taken since you added it. Rotation is the only way to invalidate copies you cannot reach.

See also

References