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

Finding → Grade Impact
Critical dotenv.config({ path: userInput }) without path validation — file read primitive via path traversal. −25 points.
Critical .env file with real secrets detected in git history (via git log --all scan). −22 points.
High Docker ARG instruction used for secrets — visible in docker history. −15 points.
High .env returned as tool result (values exposed to LLM context window and logs). −12 points.
High .env file not in .gitignore — risk of accidental commit. −10 points.
Medium No pre-commit hook enforcing .env exclusion — accidental commit not blocked at the source. −5 points.

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 →