Security reference · Secrets management · Configuration
MCP server .env file security
MCP servers that manage or load .env files face three distinct credential exposure risks. Dotenv path traversal: when an MCP tool accepts a user-supplied path to a .env file and loads it with dotenv.config({ path }), an attacker can supply a path like ../../../etc/passwd or ../sibling-service/.env to read arbitrary files on the server filesystem. Docker build arg leakage: secrets passed as ARG instructions before the first FROM are stored in the image layer cache metadata and visible via docker history even after the build completes. Committed .env files: a .env containing secrets pushed to a git repository exposes those secrets in commit history permanently — removing the file in a later commit does not remove it from git log --all or clones taken before the removal.
Attack 1: dotenv path traversal
MCP servers that provide workspace or project management tools sometimes accept a .env file path as a tool argument — to set up environment variables for a specific project, or to validate what variables a repo defines. When that path is not validated, it becomes a file read primitive.
// WRONG — dotenv.config with user-supplied path
import dotenv from "dotenv";
server.tool("load_env", { envPath: z.string() }, async ({ envPath }) => {
// envPath = "../../../etc/passwd" — reads arbitrary file as KEY=VALUE pairs
// envPath = "../database-service/.env" — reads sibling service credentials
const result = dotenv.config({ path: envPath });
const keys = Object.keys(result.parsed ?? {});
return { content: [{ type: "text", text: `Loaded ${keys.length} variables: ${keys.join(", ")}` }] };
});
The dotenv parser treats any KEY=VALUE line as a valid entry. Even /etc/passwd (which uses username:password:uid:gid:...: syntax) partially parses — the first colon-separated token becomes a key name. The exposed value is the full file content interpreted as environment variables, leaking the raw file contents via the key names and values.
Safe pattern: validate that the resolved path is within an allowed directory before loading.
import path from "path";
import fs from "fs";
const ALLOWED_ENV_DIRS = [
"/home/projects",
"/workspace",
];
function validateEnvPath(envPath: string): string {
const resolved = path.resolve(envPath);
// Verify the path is within an allowed directory
const allowed = ALLOWED_ENV_DIRS.some(dir =>
resolved.startsWith(path.resolve(dir) + path.sep) ||
resolved === path.resolve(dir)
);
if (!allowed) {
throw new Error(`Env file path ${resolved} is outside allowed directories`);
}
// Verify the filename is literally ".env" or ".env.example"
const filename = path.basename(resolved);
if (filename !== ".env" && filename !== ".env.example" && !filename.match(/^\.env\.[a-z]+$/)) {
throw new Error(`Invalid env filename: ${filename}`);
}
return resolved;
}
// Additionally: never return the values — return only the key names
server.tool("load_env", { envPath: z.string() }, async ({ envPath }) => {
const validatedPath = validateEnvPath(envPath);
const content = fs.readFileSync(validatedPath, "utf8");
// Parse manually to extract only key names — never expose values
const keys = content.split("\n")
.filter(line => line && !line.startsWith("#"))
.map(line => line.split("=")[0].trim())
.filter(Boolean);
return { content: [{ type: "text", text: `Variables defined: ${keys.join(", ")}` }] };
});
Attack 2: Docker build arg leakage into layer metadata
Passing secrets to Docker builds via ARG instructions bakes them into the image layer metadata. Even if the secret is never written to disk inside the container, it appears in docker history --no-trunc <image> as the argument to the build step.
# WRONG — secret in ARG instruction is visible in docker history ARG ANTHROPIC_API_KEY RUN npm run seed --key=$ANTHROPIC_API_KEY # docker history shows: |1 ANTHROPIC_API_KEY=sk-ant-api03-... RUN npm run seed...
If the built image is pushed to a registry (even a private one), anyone who can pull the image can recover the secret via docker history.
Safe pattern: use Docker BuildKit --secret mount — secrets are mounted into the build step and never stored in a layer.
# Dockerfile — BuildKit secret mount
# syntax=docker/dockerfile:1
FROM node:22-alpine
# Secret is mounted at /run/secrets/api_key — available only during this RUN step
# It never becomes part of a layer, docker history shows only "(secret)"
RUN --mount=type=secret,id=api_key \
export ANTHROPIC_API_KEY=$(cat /run/secrets/api_key) && \
npm run seed
# Build command — inject secret from file, never appears in history DOCKER_BUILDKIT=1 docker build \ --secret id=api_key,src=.env.build \ -t my-mcp-server .
For runtime secrets (not build-time), use Docker secrets in Swarm mode or Kubernetes secrets — never environment variables baked into the image via ENV instructions:
# WRONG — API key in ENV instruction is in every layer and visible in docker inspect
ENV ANTHROPIC_API_KEY=sk-ant-api03-...
# RIGHT — inject at runtime via docker run --env-file or orchestrator secrets
# In the container, read from a mounted secrets file or from runtime env
const apiKey = process.env.ANTHROPIC_API_KEY
?? fs.readFileSync("/run/secrets/anthropic_api_key", "utf8").trim();
Attack 3: .env committed to git — secrets in history
Committing a .env file that contains real secrets exposes them permanently in git history. A later commit that removes the .env file does not remove it from the repository — anyone who can run git log --all --full-history -- .env can recover the deleted file and its secrets.
Prevention: enforce .gitignore at commit time with a pre-commit hook.
#!/bin/sh
# .git/hooks/pre-commit — reject commits that include .env files with real secrets
# Check if .env (not .env.example) is being committed
if git diff --cached --name-only | grep -q '^\\.env$'; then
echo "ERROR: Refusing to commit .env — add real secrets to .env.example instead"
echo " Real secrets must be injected via CI/CD secrets, not committed"
exit 1
fi
# Also scan for common secret patterns in staged changes
if git diff --cached | grep -qE '(sk-ant-api|ghp_|AKIA[0-9A-Z]{16}|-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY)'; then
echo "ERROR: Staged changes appear to contain API keys or private key material"
echo " Use git-secrets or detect-secrets for full scanning"
exit 1
fi
Remediation if already committed: use BFG Repo-Cleaner to purge the file from all history.
# Purge .env from all branches and tags in git history # WARNING: this rewrites history — all clones must be re-cloned after java -jar bfg.jar --delete-files .env my-repo.git cd my-repo.git git reflog expire --expire=now --all git gc --prune=now --aggressive git push --force
Rotation required after any .env commit. If a .env file with real credentials was ever committed to a repository — even to a private repo — rotate all secrets in it immediately. Assume the credentials are compromised: they may have been captured by GitHub's secret scanning, cached by a CI/CD clone, or seen by any collaborator with repo access at any point since the commit.
MCP server secrets management recommendations
| Context | Recommended approach | Anti-pattern |
|---|---|---|
| Local development | .env file in .gitignore; .env.example with placeholder values committed |
Real secrets in .env committed to git |
| Docker build-time | BuildKit --mount=type=secret |
ARG or ENV with secret values |
| Docker runtime | docker run --env-file .env (file not in image) or Docker/Kubernetes secrets |
ENV instruction in Dockerfile with secret value |
| CI/CD | Repository secrets (GitHub Actions secrets.*, CircleCI env vars) |
Secrets in .env files committed to repo; secrets in workflow YAML |
| Production | Vault, AWS Secrets Manager, GCP Secret Manager — fetched at startup | .env file on production server filesystem |
SkillAudit findings
Run an env file security audit. SkillAudit scans for dotenv path traversal patterns, Docker build arg secrets, git history .env exposure, and missing .gitignore entries in MCP server repositories. Audit your server →