Topic: Environment variable security

MCP server environment variable security — dotenv vault, never log env dumps, SECRET_ convention

Environment variables are the primary mechanism MCP servers use to receive API keys, database connection strings, signing secrets, and third-party credentials at runtime. They sit outside source control, which is correct, but that single protection is not enough. Silent fallbacks hide misconfiguration. Log dumps expose everything at once. Inconsistent naming defeats automated scanning. Scattered process.env access makes auditing impossible. Five patterns that eliminate each of these failure modes without adding operational complexity.

1. Never use || fallback patterns — requireEnv() startup crash

The most common environment variable mistake in MCP server code looks harmless: const apiKey = process.env.OPENAI_API_KEY || ''. The server starts. No error is thrown. The first tool call that needs the API key either silently sends an empty string to the upstream service — which may still return a 401 — or, worse, skips the call entirely because the empty string is treated as a skip condition somewhere downstream. The actual misconfiguration is invisible until a user reports broken behavior.

The correct pattern is to make a missing required variable a hard startup failure. Write a requireEnv() helper that reads the variable, throws a descriptive error if it is absent or empty, and returns the string value typed as string (not string | undefined). Call it during module initialization — before the MCP server begins listening — so misconfiguration surfaces immediately in deployment logs, not in production request traces.

// env.ts — load and validate all environment variables at module init

function requireEnv(name: string): string {
  const value = process.env[name]
  if (value === undefined || value.trim() === '') {
    // Write directly to stderr — logger may not be initialized yet
    process.stderr.write(`[startup] Missing required environment variable: ${name}\n`)
    process.exit(1)
  }
  return value
}

// All required variables validated at import time, before server.listen()
export const config = {
  openaiApiKey:    requireEnv('SECRET_OPENAI_API_KEY'),
  databaseUrl:     requireEnv('SECRET_DATABASE_URL'),
  jwtSecret:       requireEnv('SECRET_JWT_SIGNING_KEY'),
  allowedOrigins:  (process.env.ALLOWED_ORIGINS ?? '').split(',').filter(Boolean),
} as const

// Type inference: config.openaiApiKey is `string`, not `string | undefined`
// No || fallback, no runtime surprises, no silent empty-string credentials

Notice that process.exit(1) is intentional. MCP servers running under a process supervisor (systemd, Docker, Kubernetes) will be restarted automatically, and the restart will surface the configuration error in logs immediately. An alternative to process.exit is throwing an unhandled error — either approach works as long as the process terminates before accepting connections. Do not catch and continue.

2. Dotenv vs vault: when each is appropriate

The dotenv package reads a .env file from the filesystem and merges its contents into process.env. It is the right tool for local development: it is simple, universally understood, and keeps secrets off the command line. It is the wrong tool for production for several reasons: .env files are written to disk in plaintext, they are not audited, they cannot be rotated without redeployment, and a compromised host exposes every secret in the file simultaneously.

In staging and production environments, use a secrets manager that injects variables at container or process startup. Doppler is the easiest to adopt — it provides a CLI that wraps your start command and injects variables from a project-environment configuration. AWS Secrets Manager and HashiCorp Vault offer more control at the cost of additional setup. The critical property all of these share is that secrets are never written to the filesystem of the host running your MCP server.

// server.ts — conditional dotenv loading
// dotenv is ONLY loaded when NODE_ENV is 'development'
// In production, variables are injected by Doppler / ECS task definition / K8s secret

if (process.env.NODE_ENV === 'development') {
  // Dynamic import keeps dotenv out of the production bundle
  const { config: dotenvConfig } = await import('dotenv')
  const result = dotenvConfig({ path: '.env' })
  if (result.error) {
    process.stderr.write(`[startup] dotenv load failed: ${result.error.message}\n`)
    process.exit(1)
  }
}

// After conditional dotenv load, run the requireEnv checks from env.ts
// In production, the variables are already present — dotenv block is skipped entirely
import { config } from './env.js'

// ---
// Doppler usage in production (no code change needed):
//   doppler run -- node dist/server.js
//
// AWS ECS: inject via task definition secretsManagerArn references
// K8s: mount as envFrom secretRef, never as a mounted file volume

One common mistake is committing .env to source control because the developer added it to .gitignore at the project root but the MCP server lives in a subdirectory with its own git root. Verify that .gitignore rules cover all subdirectories where .env files might exist, and add a pre-commit hook that rejects any staged file whose name matches .env or .env.* (excluding .env.example).

3. Never log process.env dumps — filtering sensitive keys

Structured logging libraries like Pino, Winston, and Bunyan serialize JavaScript objects deeply by default. A single call like logger.debug({ env: process.env }, 'startup config') or logger.error({ context: req }, 'handler failed') where req has an env property will emit every environment variable — including all secrets — into your log stream. These logs often flow to aggregators like Datadog, Splunk, or CloudWatch, where they are retained, indexed, and accessible to anyone with log read permissions.

The fix has two parts. First, never pass process.env or any object that might transitively contain it to a logging call. Second, if you need to log startup configuration for diagnostics, write an explicit allowlist that selects only non-sensitive keys. Use process.stderr.write for startup diagnostics rather than the structured logger — the logger may itself be misconfigured and forward output to an external sink.

// safe-startup-log.ts — log only non-sensitive config keys at startup

const SAFE_ENV_KEYS = new Set([
  'NODE_ENV',
  'PORT',
  'LOG_LEVEL',
  'ALLOWED_ORIGINS',
  'SERVER_REGION',
])

function logSafeStartupConfig(): void {
  const safe: Record<string, string> = {}

  for (const key of SAFE_ENV_KEYS) {
    const value = process.env[key]
    if (value !== undefined) {
      safe[key] = value
    }
  }

  // process.stderr.write bypasses the structured logger entirely
  process.stderr.write(`[startup] config: ${JSON.stringify(safe)}\n`)
}

// What NOT to do — never write any of these:
// logger.info(process.env)                     // dumps all secrets
// logger.info({ ...process.env })              // same, spread form
// logger.error({ req: ctx }, 'failed')         // ctx may contain env transitively
// JSON.stringify(process.env)                  // plaintext credential dump
// Object.fromEntries(Object.entries(process.env)) // same

It is also worth auditing your error handling middleware. A common pattern in Express and Fastify error handlers is to log the full request context on unhandled errors. If the request context includes anything that was derived from environment variables — connection pool objects, authenticated client instances, configuration structs — those objects may contain the raw secret values. Sanitize error log objects explicitly rather than relying on logger redaction plugins, which are easy to misconfigure and silently bypass.

4. SECRET_ naming convention for automated detection

Without a naming convention, environment variables that hold sensitive values are indistinguishable from variables that hold innocuous configuration. process.env.API_KEY is sensitive. process.env.API_BASE_URL is not. A SAST tool, a pre-commit hook, or a code reviewer cannot reliably tell them apart without reading every usage site. The SECRET_ prefix convention solves this by making sensitivity a property of the variable name itself.

Any variable whose value should never appear in logs, never be committed to source control, and never be passed to non-security-reviewed code paths gets the SECRET_ prefix: SECRET_DATABASE_URL, SECRET_OPENAI_API_KEY, SECRET_JWT_SIGNING_KEY. SAST tools like Semgrep can then be configured to flag any code path where a SECRET_-prefixed variable is passed to a logger, serializer, or HTTP response. The .env.example file lists all variable names with empty or placeholder values — no real secrets — so new developers know what variables are needed without having access to the actual values.

# .env.example — committed to source control, values are placeholders only
# Copy to .env for local development, fill in real values, NEVER commit .env

NODE_ENV=development
PORT=3000
LOG_LEVEL=info
ALLOWED_ORIGINS=http://localhost:3000

# SECRET_ prefix: these must NEVER appear in logs or source control
SECRET_DATABASE_URL=postgres://user:password@localhost:5432/dbname
SECRET_OPENAI_API_KEY=sk-...
SECRET_JWT_SIGNING_KEY=change-me-min-32-chars
SECRET_WEBHOOK_SIGNING_SECRET=whsec_...
# .semgrep/secret-leak.yml — flag SECRET_ variables passed to loggers
rules:
  - id: secret-env-in-logger
    patterns:
      - pattern: $LOGGER.$METHOD(..., process.env.$KEY, ...)
      - metavariable-regex:
          metavariable: $KEY
          regex: '^SECRET_'
    message: "SECRET_ env variable $KEY passed to logger — potential credential leak"
    severity: ERROR
    languages: [typescript, javascript]

5. Scoping env access to the tools that need it

Inline process.env access inside tool handler functions creates several problems. The call site is invisible unless you grep for it — there is no single place to audit what secrets an MCP server accesses. TypeScript types the value as string | undefined, so every handler that reads the variable must re-implement the undefined check. If the variable name is misspelled, the error occurs at runtime during a real request rather than at startup. And if the variable is read in multiple handlers, a rename requires finding every call site.

The correct pattern is to read each environment variable exactly once, at module initialization time, in a dedicated configuration module. The configuration module exports a typed object where every field is string, not string | undefined — the requireEnv() helper handles the undefined check at startup. Tool handler modules import this configuration object rather than calling process.env directly. The result is that all environment variable access is visible in one file, and no tool handler can accidentally introduce a new process.env call that bypasses the startup validation.

// config.ts — single source of truth for all env access
import { requireEnv } from './require-env.js'

export const config = {
  db: {
    url: requireEnv('SECRET_DATABASE_URL'),
  },
  openai: {
    apiKey: requireEnv('SECRET_OPENAI_API_KEY'),
    model:  process.env.OPENAI_MODEL ?? 'gpt-4o',
  },
  auth: {
    jwtSecret: requireEnv('SECRET_JWT_SIGNING_KEY'),
  },
} as const

// ---
// tools/search.ts — imports config, never calls process.env directly
import { config } from '../config.js'
import OpenAI from 'openai'

// Client is created once at module load using the already-validated key
const openai = new OpenAI({ apiKey: config.openai.apiKey })

export async function handleSearchTool(query: string): Promise<string> {
  // No process.env here — config.openai.apiKey is typed as string
  const response = await openai.chat.completions.create({
    model: config.openai.model,
    messages: [{ role: 'user', content: query }],
  })
  return response.choices[0]?.message.content ?? ''
}

// tools/database.ts — different module, same config import
import { config } from '../config.js'
import { Pool } from 'pg'

// Only the db section of config is used here — clear audit trail
const pool = new Pool({ connectionString: config.db.url })

This pattern also makes testing straightforward. Tests can import config.ts after setting the required environment variables, or mock the module entirely. With inline process.env calls scattered through handler code, tests must set every variable used by every handler they call, or risk undefined access errors that have nothing to do with what the test is actually testing.

Audit your MCP server's environment variable handling

SkillAudit checks for silent env fallbacks, potential credential log leaks, and scattered process.env access patterns across your MCP server codebase.

See pricing