MCP server secrets management: Vault, AWS Secrets Manager, and the .env anti-pattern

Most MCP servers ship with their secrets in a .env file and a line in the README that says "don't commit this." That instruction fails — 18% of community MCP server repositories already have a .env committed somewhere in their git history. This guide replaces the .env habit with production-grade secrets management: runtime fetch from Vault or AWS Secrets Manager, automatic rotation without restart, and an audit trail for every secret access.

Why .env files are the wrong answer

The .env convention emerged from the twelve-factor app methodology, where it was never intended as a security control — it was a developer ergonomics tool to keep local config out of version control. Somewhere along the way, the ecosystem conflated "not checked in by default" with "secure." It is not. A .env file sitting on a production server is a plaintext credential store with five distinct exposure vectors.

SkillAudit scan finding: 61% of community MCP servers store secrets in .env files. Of those, 18% have at least one .env file committed somewhere in their git history — often in an early commit before a .gitignore was added, surviving through every subsequent clone and fork.

1Git commit accidents

A .env file committed before .gitignore is added persists forever in git's object store. git log --all --full-diff -- .env recovers it from any clone. Removing the file in a subsequent commit does not remove it from history — only git-filter-repo rewrites the object graph.

2Docker inspect exposes env vars

When .env values are passed as Docker environment variables via --env-file .env or docker-compose env_file:, every container runtime that has access to the Docker daemon can read them verbatim: docker inspect <container> dumps the full Env array in plaintext JSON. Any CI system, monitoring agent, or shared deploy tool with Docker socket access sees your secrets.

3/proc/PID/environ in shared hosting

On Linux, /proc/<pid>/environ contains the full environment of a process as a null-delimited string. On shared hosts and poorly-configured container platforms, co-tenant processes running as the same OS user — or as root — can read the environment of every other process. If your MCP server's DATABASE_URL is in process.env, it is readable from /proc.

4CI log output

CI systems routinely print environment variables in debug mode, in error traces, and when steps print their own environment for troubleshooting. GitHub Actions masks secrets that are explicitly registered as repository secrets — but values that arrive via a checked-in .env file or a generic env var are not masked and appear in workflow logs in plaintext.

5Error reporting SDK serialization

Sentry, Datadog APM, New Relic, and similar SDKs capture the state of the Node.js process on unhandled exceptions. Their default configuration serializes process.env as part of the error context and transmits it to the vendor's servers. Any secret in your environment is now in your error reporting platform — often shared across a whole engineering team and retained for 90 days or more.

The SkillAudit scanner checks for all five vectors. The three secrets it finds most often in community MCP server repositories are AWS_ACCESS_KEY_ID, DATABASE_URL, and OPENAI_API_KEY — all high-value targets that would allow an attacker to access cloud infrastructure, production data, or AI API billing accounts.

The runtime fetch pattern

The correct alternative to dotenv is to fetch secrets from a secrets manager at server startup, hold them in a sealed in-memory object, and never write them back to process.env. This breaks the exposure vectors above: there is no file on disk to commit, nothing in process.env to inspect or serialize, and the secret value is a single in-memory string whose lifecycle you control.

WRONG — dotenv loaded into process.env at startup

// WRONG: secrets land in process.env — a shared, mutable, globally-accessible object.
// Any require()'d module, any error reporter, any docker inspect call can read them.
import 'dotenv/config';

const db = new DatabaseClient({
  connectionString: process.env.DATABASE_URL,  // WRONG: read from global env
  apiKey: process.env.OPENAI_API_KEY,          // WRONG: any module can mutate this
});

// WRONG: process.env is mutable — a buggy module can accidentally overwrite it
// process.env.DATABASE_URL = 'corrupted-value'; // silently accepted by Node.js

RIGHT — fetch from AWS Secrets Manager at startup, store in a frozen object

// RIGHT: fetch secrets once at startup from a managed store.
// Store in a sealed object — never in process.env.
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

const smClient = new SecretsManagerClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
// Note: AWS_REGION is configuration, not a secret. It is fine in env.
// Credentials come from the EC2/ECS instance metadata service — NOT from env vars.

async function loadSecrets() {
  const [dbSecret, openaiSecret] = await Promise.all([
    smClient.send(new GetSecretValueCommand({ SecretId: 'prod/mcp-server/database' })),
    smClient.send(new GetSecretValueCommand({ SecretId: 'prod/mcp-server/openai' })),
  ]);

  const db = JSON.parse(dbSecret.SecretString);
  const ai = JSON.parse(openaiSecret.SecretString);

  // RIGHT: store in a frozen object — Object.freeze prevents accidental mutation
  // and makes it immediately obvious to code reviewers that this is sealed config.
  return Object.freeze({
    databaseUrl: db.connection_string,
    openaiApiKey: ai.api_key,
  });
}

// RIGHT: await secrets before registering any MCP tool handlers
const secrets = await loadSecrets();

// RIGHT: pass secrets explicitly to the components that need them
const db = new DatabaseClient({ connectionString: secrets.databaseUrl });
const aiClient = new OpenAIClient({ apiKey: secrets.openaiApiKey });

Why not just write to process.env after fetching? A pattern you'll sometimes see is fetching from a secrets manager and then writing values back into process.env to preserve compatibility with libraries that expect environment variables. This defeats most of the security benefit — you're back to a mutable global that error reporters can serialize. Pass secrets as constructor arguments or via dependency injection instead.

Scoping secrets to the narrowest context

process.env is a process-wide mutable object. Every module in your Node.js process — including every transitive dependency — can read from and write to it. When you store your OPENAI_API_KEY in process.env, you are sharing it with every npm package your server has ever installed, including ones you've never audited.

The correct pattern is dependency injection: pass secrets as constructor arguments or function parameters to the specific component that needs them, and nowhere else. This also makes secrets easy to mock in tests, and makes your security surface immediately legible from the function signature.

WRONG — module reads from process.env directly

// WRONG: openai-client.js reads from process.env directly.
// This means the secret must be in process.env for the module to work,
// which forces you to store it there — and gives every other module access to it too.

// openai-client.js
export class OpenAIClient {
  constructor() {
    // WRONG: reads from a global — invisible dependency, untestable, globally exposed
    this.apiKey = process.env.OPENAI_API_KEY;
    if (!this.apiKey) throw new Error('OPENAI_API_KEY not set');
  }

  async complete(prompt) {
    // uses this.apiKey
  }
}

// main.js — the caller has no idea where the secret comes from
const client = new OpenAIClient(); // WRONG: magic dependency on process.env

RIGHT — API key injected via constructor argument

// RIGHT: openai-client.js receives its API key as a constructor argument.
// The secret never touches process.env. The caller controls the secret's lifetime.
// Tests can inject a mock key without any environment manipulation.

// openai-client.js
export class OpenAIClient {
  #apiKey; // RIGHT: private field — not accessible from outside the class

  constructor({ apiKey }) {
    if (typeof apiKey !== 'string' || apiKey.length === 0) {
      throw new TypeError('OpenAIClient requires a non-empty apiKey');
    }
    this.#apiKey = apiKey;
  }

  async complete(prompt) {
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.#apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }] }),
    });
    return response.json();
  }
}

// main.js — the secret's provenance is explicit and auditable
const secrets = await loadSecrets(); // from Vault or AWS Secrets Manager
const client = new OpenAIClient({ apiKey: secrets.openaiApiKey }); // RIGHT: injected

This pattern extends naturally to MCP tool handlers. Rather than accessing a module-level process.env variable inside a server.tool() handler, close over the injected secret in the handler factory. This also makes it trivial to swap in a refreshed secret when rotation occurs — which brings us to the next pattern.

Secret rotation without restart

Secrets expire. AWS Secrets Manager can be configured to rotate a database password on a 30-day schedule; Vault leases have TTLs measured in hours. A server that reads secrets once at startup and caches them for its lifetime will fail on the next request after a rotation event. The naive fix — restarting the server — creates downtime and requires operational coordination every time a secret rotates.

The correct pattern is a refreshing cache: an in-memory store that tracks the age of each cached secret and re-fetches transparently when the TTL expires. The cache returns the cached value for most calls (fast path), and re-fetches asynchronously or synchronously only when the TTL is exceeded. Tool handlers call secretCache.get('secret-name') — they are never aware of rotation happening underneath.

RIGHT — SecretCache class with TTL-based refresh

// secret-cache.js
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes — re-fetch well before rotation window

export class SecretCache {
  #client;
  #ttlMs;
  #store = new Map(); // secretId -> { value, fetchedAt, inflightPromise }

  constructor({ region, ttlMs = DEFAULT_TTL_MS } = {}) {
    this.#client = new SecretsManagerClient({ region: region ?? 'us-east-1' });
    this.#ttlMs = ttlMs;
  }

  async get(secretId) {
    const entry = this.#store.get(secretId);
    const now = Date.now();

    // Return cached value if still fresh
    if (entry && !entry.inflightPromise && now - entry.fetchedAt < this.#ttlMs) {
      return entry.value;
    }

    // If a fetch is already in flight for this secret, reuse the same promise
    // to avoid thundering-herd fetches when multiple tool handlers call get() concurrently
    if (entry?.inflightPromise) {
      return entry.inflightPromise;
    }

    // Kick off a new fetch
    const inflightPromise = this.#fetch(secretId).then((value) => {
      // Atomic swap: replace the stale entry with the fresh value
      this.#store.set(secretId, { value, fetchedAt: Date.now(), inflightPromise: null });
      return value;
    }).catch((err) => {
      // On fetch failure: clear inflight, return stale value if available, else throw
      this.#store.set(secretId, entry ? { ...entry, inflightPromise: null } : undefined);
      if (entry) {
        console.warn({ secretId, err }, 'Secret refresh failed — using stale value');
        return entry.value;
      }
      throw err;
    });

    // Record the inflight promise so concurrent callers reuse it
    this.#store.set(secretId, { ...(entry ?? {}), inflightPromise });
    return inflightPromise;
  }

  async #fetch(secretId) {
    const cmd = new GetSecretValueCommand({ SecretId: secretId });
    const resp = await this.#client.send(cmd);
    // SecretString for JSON/plaintext secrets; SecretBinary for binary blobs
    const raw = resp.SecretString ?? Buffer.from(resp.SecretBinary).toString('utf-8');
    return JSON.parse(raw);
  }
}

// Usage in your MCP server entrypoint:
const secretCache = new SecretCache({ region: 'us-east-1', ttlMs: 4 * 60 * 1000 });

// Tool handler — fetches fresh secret on each call if TTL exceeded, cached otherwise
server.tool('query-database', async ({ sql }) => {
  const { connection_string } = await secretCache.get('prod/mcp-server/database');
  const db = await getPooledConnection(connection_string);
  return db.query(sql);
});

Set your TTL well below the rotation window. If AWS Secrets Manager rotates your database password every 30 days, a 5-minute TTL means the worst-case staleness after rotation is 5 minutes — not 30 days. For Vault leases with a 1-hour TTL, refresh at 45 minutes. Always build in a buffer so rotation and refresh don't race.

HashiCorp Vault integration for MCP servers

Vault is a popular choice for organizations that run their own infrastructure or want more control over the secrets management lifecycle than cloud-managed services provide. The key auth mechanism for automated workloads is AppRole: a machine-identity auth method that issues a Vault token in exchange for a RoleID and a SecretID. The RoleID is effectively a username — it identifies which application is authenticating. The SecretID is a one-time-use password that wraps a short-lived token.

The operational challenge is the bootstrapping problem: you need a secret (the SecretID) to get your secrets. The naive solution — storing the SecretID in a .env file — recreates the problem you were trying to solve. The correct pattern is Vault's Response Wrapping: a Vault agent or init container fetches a wrapped token for the SecretID and delivers it to the application's filesystem via a tmpfs mount. The wrapped token is single-use and short-lived, so exposure after delivery is harmless.

WRONG — SecretID hardcoded in environment variable

// WRONG: SecretID in environment variable recreates the .env problem.
// Anyone with docker inspect access or /proc access can retrieve it.
// A leaked SecretID can be used to impersonate your application to Vault.

import 'dotenv/config';

const vaultToken = await fetchVaultToken({
  roleId: process.env.VAULT_ROLE_ID,       // WRONG: leaked via process.env
  secretId: process.env.VAULT_SECRET_ID,   // WRONG: plaintext credential in env
  vaultAddr: process.env.VAULT_ADDR,
});

// Now all your real secrets are accessible — but the credential to fetch them
// is sitting in a place every module, error reporter, and sidecar can read.

RIGHT — SecretID delivered via wrapped token from Vault agent, read from tmpfs

// RIGHT: Vault agent runs as a sidecar and writes a wrapped token to a tmpfs mount. // The app reads the wrapped token from the filesystem — single-use, expires in 60s. // After unwrapping, the SecretID is consumed; the wrapped token is now worthless. import { readFile } from 'node:fs/promises'; import fetch from 'node-fetch'; const VAULT_ADDR = 'https://vault.internal.example.com'; // non-secret configuration async function bootstrapVaultToken() { // RIGHT: wrapped token delivered to a tmpfs path by Vault agent init container // The path is a contract between the init container and the app — not a secret itself const wrappedToken = (await readFile('/run/secrets/vault-wrapped-token', 'utf-8')).trim(); // Step 1: unwrap to get the SecretID (single-use — destroys the wrapped token) const unwrapResp = await fetch(`${VAULT_ADDR}/v1/sys/wrapping/unwrap`, { method: 'POST', headers: { 'X-Vault-Token': wrappedToken, 'Content-Type': 'application/json' }, }); if (!unwrapResp.ok) throw new Error(`Vault unwrap failed: ${unwrapResp.status}`); const { data: { secret_id: secretId } } = await unwrapResp.json(); // RoleID is non-sensitive configuration — safe to store in env or a config file const roleId = process.env.VAULT_ROLE_ID; // Step 2: AppRole login — exchange RoleID + SecretID for a Vault token const loginResp = await fetch(`${VAULT_ADDR}/v1/auth/approle/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role_id: roleId, secret_id: secretId }), }); if (!loginResp.ok) throw new Error(`Vault AppRole login failed: ${loginResp.status}`); const { auth: { client_token: vaultToken, lease_duration: leaseDurationSec } } = await loginResp.json(); // RIGHT: schedule token renewal before lease expires const renewAfterMs = (leaseDurationSec * 0.75) * 1000; setTimeout(() => renewVaultToken(vaultToken), renewAfterMs); return vaultToken; } async function readVaultSecret(vaultToken, path) { const resp = await fetch(`${VAULT_ADDR}/v1/${path}`, { headers: { 'X-Vault-Token': vaultToken }, }); if (!resp.ok) throw new Error(`Vault read failed: ${resp.status} at ${path}`); const { data } = await resp.json(); return data; // KV v2: data.data; KV v1: data directly } // Bootstrap at startup const vaultToken = await bootstrapVaultToken(); const dbSecret = await readVaultSecret(vaultToken, 'secret/data/prod/mcp-server/database'); const secrets = Object.freeze({ databaseUrl: dbSecret.data.connection_string });

Rotate your AppRole SecretIDs regularly. A SecretID should be configured with secret_id_num_uses: 1 (single-use) for bootstrapping. For long-running servers, configure Vault to issue a SecretID with a short TTL — the RoleID/SecretID pair is only needed once, at boot time, to get a Vault token. After that, the Vault token itself authenticates all secret reads. Token renewal is a low-risk operation that uses the token to extend itself, not the original credentials.

AWS Secrets Manager integration

AWS Secrets Manager is the managed option for workloads running on AWS infrastructure. Its key advantage over Vault for AWS-native deployments is that authentication is handled entirely by AWS Identity and Access Management — you don't need to bootstrap any credentials at all. EC2 instances, ECS tasks, and Lambda functions are assigned an IAM role, and the AWS SDK automatically fetches short-lived credentials from the EC2 Instance Metadata Service (IMDS) or the ECS task metadata endpoint. The credentials rotate every few hours and are never stored anywhere the application can see.

WRONG — AWS access keys in environment variables

// WRONG: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in environment variables.
// These are long-lived credentials that don't rotate automatically.
// They are leaked by docker inspect, /proc/PID/environ, error reporters, and CI logs.
// The SkillAudit scanner flags this pattern as a Critical finding.

import 'dotenv/config';

const smClient = new SecretsManagerClient({
  region: 'us-east-1',
  credentials: {
    // WRONG: static credentials in process.env — exposed to every module
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

RIGHT — no credentials in environment, rely on instance/task role

// RIGHT: no credentials anywhere in the application. // The AWS SDK's credential provider chain automatically uses: // 1. EC2 instance metadata service (IMDS) — for EC2 deployments // 2. ECS task role — for ECS/Fargate deployments // 3. EKS IAM roles for service accounts (IRSA) — for Kubernetes deployments // Short-lived credentials rotate automatically every ~6 hours. // No credential material ever touches your application code or environment. import { SecretsManagerClient, GetSecretValueCommand, ListSecretVersionIdsCommand, } from '@aws-sdk/client-secrets-manager'; // RIGHT: no credentials argument — SDK uses the credential provider chain const smClient = new SecretsManagerClient({ region: 'us-east-1' }); async function getSecret(secretId) { const cmd = new GetSecretValueCommand({ SecretId: secretId, // RIGHT: omit VersionStage to get the current AWSCURRENT version. // During rotation, AWS briefly maintains AWSPENDING alongside AWSCURRENT — // always fetch AWSCURRENT to get the live credential. VersionStage: 'AWSCURRENT', }); const resp = await smClient.send(cmd); // SecretString: JSON or plaintext secrets (most common) if (resp.SecretString) { return JSON.parse(resp.SecretString); } // SecretBinary: used for binary material like TLS private keys, certificate bundles if (resp.SecretBinary) { // SecretBinary arrives as a Uint8Array — decode for use return Buffer.from(resp.SecretBinary).toString('utf-8'); } throw new Error(`Secret ${secretId} has neither SecretString nor SecretBinary`); } // RIGHT: ECS Task Definition assigns the task role via taskRoleArn — no keys needed // { // "taskRoleArn": "arn:aws:iam::123456789012:role/mcp-server-task-role", // ... // } // // RIGHT: IAM policy on the task role scoped to exactly the secrets this server needs // { // "Effect": "Allow", // "Action": ["secretsmanager:GetSecretValue"], // "Resource": [ // "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/mcp-server/*" // ] // }

The task role approach means that if your MCP server is compromised, the attacker gets access only to the secrets your task role can read — not to long-lived AWS access keys that could be used to access your entire AWS account. Combined with IAM policy conditions (requiring requests originate from the VPC, restricting by resource tag), this is a strong least-privilege posture that SkillAudit looks for when grading server audits.

Audit trails for secret access

Secrets management is not just about preventing unauthorized access — it's about knowing when, by whom, and from which context a secret was accessed. In a SOC 2 Type II audit or a PCI-DSS assessment, you will be asked to demonstrate that you have controls over who accessed which credentials and when. A production MCP server should emit a structured log event for every secret fetch.

The SkillAudit observability guide covers the full logging taxonomy for MCP servers. For secret access specifically, the rules are: log the secret identifier (name, ARN, path), never the secret value; record the requesting tool handler; and emit the event before the secret is used so it's captured even if the subsequent operation crashes.

REQUIRED
secret_id — the secret name or ARN, never the value. Used to correlate access events across time and identify which secret is being rotated or reviewed.
REQUIRED
requestor — the MCP tool handler name or internal component that requested the secret. Enables you to detect tool handlers that are accessing secrets outside their intended scope.
REQUIRED
source — whether the value came from cache or a live fetch. A sudden spike in live fetches indicates cache problems or rotation events; unexpected fetches by unfamiliar requestors may indicate compromise.
FORBIDDEN
secret_value — never log the actual secret. Not even the first few characters for "debugging purposes." Logs are often less protected than secrets stores and are retained for long periods. A secret in a log is a secret breach.
// audit-logger.js — structured log emitter for secret access events

export function logSecretAccess({ secretId, requestor, source, success, durationMs }) {
  // RIGHT: structured JSON log — parseable by any SIEM or log aggregator
  // RIGHT: logs the secret *identifier*, never the secret *value*
  const event = {
    event: 'secret.access',
    timestamp: new Date().toISOString(),
    secret_id: secretId,        // RIGHT: name/ARN only
    requestor,                  // which tool handler or component fetched this
    source,                     // 'cache' | 'live-fetch'
    success,                    // false if fetch threw an error
    duration_ms: durationMs,    // slow fetches indicate Vault/SM availability issues
    // WRONG would be: secret_value: actualSecret  <-- never do this
  };

  // Emit to stdout — let the logging infrastructure handle routing
  // In production, this goes to CloudWatch Logs, Datadog, or your SIEM
  process.stdout.write(JSON.stringify(event) + '\n');
}

// Wrap SecretCache.get() to emit audit events automatically
export class AuditedSecretCache extends SecretCache {
  async get(secretId, { requestor = 'unknown' } = {}) {
    const start = Date.now();
    const cachedEntry = this._peekCache?.(secretId); // check without fetching
    try {
      const value = await super.get(secretId);
      logSecretAccess({
        secretId,
        requestor,
        source: cachedEntry ? 'cache' : 'live-fetch',
        success: true,
        durationMs: Date.now() - start,
      });
      return value;
    } catch (err) {
      logSecretAccess({ secretId, requestor, source: 'live-fetch', success: false, durationMs: Date.now() - start });
      throw err;
    }
  }
}

With this pattern, your audit log shows a clear timeline: which tool handler accessed which secret, whether it was served from cache or triggered a live fetch, and how long the operation took. Anomalies — a tool handler accessing a secret it has never touched before, a burst of live fetches during off-hours — are immediately visible to your security monitoring stack. For guidance on what else to log and what detection rules to build on top of your MCP server logs, see the observability and security logging guide.

Quick wins

If you're maintaining an existing MCP server that currently uses .env files, you don't have to migrate everything at once. These three actions reduce your exposure significantly and can each be completed in under an hour.

  • 1

    Purge secrets from git history with git-filter-repo

    Run git log --all --full-history --diff-filter=D -- "*.env" ".env*" to identify commits that touched .env files. Then use git-filter-repo --path .env --invert-paths to rewrite the object graph and remove the file from all commits. Force-push all branches and immediately revoke and rotate any credentials that were committed. Every collaborator must re-clone — warn them before you push. For patterns rather than files, use git-filter-repo --replace-text replacements.txt with a replacements file that maps literal secret values to ***REMOVED***.

  • 2

    Add a pre-commit hook that blocks .env commits

    Create .git/hooks/pre-commit (or use a tool like husky to manage it as a project-level hook) with a script that fails the commit if any staged file matches \.env$, \.env\., or common secret patterns. The SkillAudit security checklist includes a ready-to-use pre-commit hook script. For CI, add a git secrets or truffleHog step that scans each pull request for credential patterns and blocks merge on a match. Pre-commit hooks are bypassed by git commit --no-verify, so the CI check is the enforcing control — the hook is a developer convenience.

  • 3

    Wrap process.env reads in a single validated accessor

    Before you complete the migration to a secrets manager, consolidate all process.env reads into a single getConfig() function that validates at startup and throws a descriptive error on missing required keys. This eliminates the class of bugs where a secret is absent and the application silently falls back to an unexpected behavior — it fails fast with a clear message. It also gives you a single grep target (process.env outside of config.js) to enforce during code review, and a natural seam to swap in secrets-manager calls later without touching every callsite.

// config.js — single validated accessor for all environment-sourced config
// This is the ONLY file that should read from process.env.
// All other modules receive config as constructor arguments or function parameters.

function requireEnv(key) {
  const value = process.env[key];
  if (value === undefined || value === '') {
    // Fail at startup — don't let the server start with missing config.
    // This surfaces misconfiguration immediately rather than on the first request.
    throw new Error(
      `Required environment variable "${key}" is not set. ` +
      `Check your deployment config or secrets manager integration.`
    );
  }
  return value;
}

// RIGHT: all process.env reads are here and only here
export const config = Object.freeze({
  // Non-sensitive configuration — fine in env vars
  awsRegion: requireEnv('AWS_REGION'),
  vaultAddr: process.env.VAULT_ADDR ?? 'https://vault.internal.example.com',
  port: parseInt(process.env.PORT ?? '3000', 10),
  nodeEnv: process.env.NODE_ENV ?? 'production',

  // Secrets are NOT here — they come from the secrets manager at runtime.
  // This makes it structurally impossible to accidentally log or expose them
  // via config.toString() or JSON.stringify(config).
});

// Usage elsewhere — import config, not process.env
import { config } from './config.js';
const secretCache = new SecretCache({ region: config.awsRegion });

Secrets management is one of the highest-leverage security improvements you can make to an MCP server. A single leaked API key can result in unauthorized AI API charges, data exfiltration from your database, or lateral movement through your cloud infrastructure. The patterns in this guide — runtime fetch, dependency injection, refreshing caches, and Vault/ASM integration — form a layered defense that addresses each of the five exposure vectors that .env files create.

For a comprehensive review of your MCP server's current secrets handling, run a SkillAudit scan. The scanner checks for .env patterns, process.env reads in non-config modules, static credential strings, hardcoded API key prefixes (sk-, AKIA, ghp_), and the absence of secrets manager integrations. Combined with the security checklist, it gives you a prioritized list of the exact changes that will move your server's grade.