Secrets Management: Vault, Environment Variables, and Rotation

MCP servers typically need credentials to do useful work: database passwords, API keys for third-party services, signing keys for JWTs, cloud provider credentials. How you handle these secrets determines whether a breach stays contained or cascades. This lesson covers the full secrets management lifecycle for MCP servers: the baseline (environment variables), the better (Vault integration), and the best (cloud-native secrets with rotation) – plus what never to do.

Secrets management layers diagram environment variables dotenv Vault cloud KMS rotation lifecycle dark
Secrets management is a spectrum: from simple .env files for dev to cloud KMS with rotation for production.

What Never to Do

  • Never commit credentials to source control, even in private repos
  • Never hard-code credentials in source files
  • Never put credentials in container image build args (they appear in image history)
  • Never log credentials, even partially (no “key: sk-…{first 8 chars}”)
  • Never return credentials in tool output to the LLM (it may leak them)

Level 1: Environment Variables with Node.js 22 –env-file

# .env (never commit this)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
OPENAI_API_KEY=sk-...
STRIPE_SECRET_KEY=sk_live_...
JWT_SIGNING_KEY=super-secret-signing-key

# Load in development with Node.js 22 native --env-file
# node --env-file=.env server.js
# No dotenv package needed
// Access secrets via process.env - never via object destructuring at module level
// (destructuring happens once at startup; env can be rotated in some setups)

function getDatabaseUrl() {
  const url = process.env.DATABASE_URL;
  if (!url) throw new Error('DATABASE_URL is required');
  return url;
}

// In Docker, pass via --env-file or -e flags, not build args
// docker run --env-file=.env.prod my-mcp-server

Level 2: HashiCorp Vault Integration

Vault provides centralized secrets management, dynamic credentials, and audit logging. The Node.js client is straightforward:

npm install node-vault
import vault from 'node-vault';

class SecretsManager {
  #client;
  #cache = new Map();

  constructor() {
    this.#client = vault({
      endpoint: process.env.VAULT_ADDR,
      token: process.env.VAULT_TOKEN,  // Or use AppRole auth
    });
  }

  async getSecret(path) {
    if (this.#cache.has(path)) {
      const cached = this.#cache.get(path);
      if (cached.expiresAt > Date.now()) return cached.value;
    }

    const { data } = await this.#client.read(path);
    // Cache for 5 minutes
    this.#cache.set(path, { value: data.data, expiresAt: Date.now() + 5 * 60_000 });
    return data.data;
  }

  async getDatabaseCredentials() {
    // Vault dynamic secrets: generates a fresh DB user for each request
    const creds = await this.#client.read('database/creds/mcp-server-role');
    return {
      username: creds.data.username,
      password: creds.data.password,
      leaseId: creds.lease_id,
      leaseDuration: creds.lease_duration,
    };
  }
}

const secrets = new SecretsManager();
const dbCreds = await secrets.getDatabaseCredentials();
HashiCorp Vault dynamic database credentials flow MCP server requesting fresh credentials lease lifecycle dark
Vault dynamic credentials: each MCP server instance gets unique, short-lived database credentials that expire automatically.

Level 3: Cloud-Native Secrets (AWS/GCP/Azure)

// AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const sm = new SecretsManagerClient({ region: 'us-east-1' });

async function getAWSSecret(secretName) {
  const { SecretString } = await sm.send(new GetSecretValueCommand({ SecretId: secretName }));
  return JSON.parse(SecretString);
}

// GCP Secret Manager
import { SecretManagerServiceClient } from '@google-cloud/secret-manager';

const gsmClient = new SecretManagerServiceClient();

async function getGCPSecret(name) {
  const [version] = await gsmClient.accessSecretVersion({ name });
  return version.payload.data.toString('utf8');
}

Secret Rotation in MCP Servers

// Graceful rotation: fetch a fresh secret when a credential fails
// rather than hardcoding the rotation schedule

class RotatingApiClient {
  #apiKey = null;
  #lastFetch = 0;

  async getApiKey() {
    // Refresh every 15 minutes (Vault lease or cloud secret TTL)
    if (Date.now() - this.#lastFetch > 15 * 60 * 1000) {
      const secret = await getSecret('/mcp/api-keys/openai');
      this.#apiKey = secret.key;
      this.#lastFetch = Date.now();
    }
    return this.#apiKey;
  }

  async callApi(endpoint) {
    const key = await this.getApiKey();
    const response = await fetch(endpoint, {
      headers: { Authorization: `Bearer ${key}` },
    });
    if (response.status === 401) {
      // Key may have been rotated externally - force refresh
      this.#lastFetch = 0;
      const freshKey = await this.getApiKey();
      return fetch(endpoint, { headers: { Authorization: `Bearer ${freshKey}` } });
    }
    return response;
  }
}

Secrets in MCP Server Configuration Files

MCP clients configure servers in JSON config files (Claude Desktop’s claude_desktop_config.json, for example). These files often end up in version control. Use environment variable references instead:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["./server.js"],
      "env": {
        "DATABASE_URL": "${DATABASE_URL}",
        "API_KEY": "${MY_SERVER_API_KEY}"
      }
    }
  }
}

The MCP SDK resolves ${VAR_NAME} references from the parent process’s environment at launch time. The config file itself never contains the secret values.

Common Secrets Failures

  • Secrets in LLM context: Never pass credentials as part of tool descriptions, prompts, or tool results. An LLM that has seen a secret can reproduce it in its output. Use a lookup-by-name pattern instead.
  • Long-lived tokens: API keys that never expire are a permanent risk if leaked. Use tokens with expiry and rotate them on a schedule.
  • No secret access audit: Vault and cloud KMS providers log every secret access. If you are not using these logs, you have no way to detect credential exfiltration.
  • Broad IAM permissions: A service account that can read all secrets is a single point of failure. Scope each MCP server’s IAM policy to only the secrets it needs.

What to Build Next

  • Audit your current MCP server: list every process.env access and verify each secret is loaded from a secure source, not hardcoded or committed.
  • Add Vault or your cloud KMS to your local dev environment and replace one hardcoded credential with a dynamic fetch.

nJoy 😉

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.