Guide · Security

MCP server secrets management

An MCP server calls databases, external APIs, and message queues — all of which require credentials. How those credentials reach the running process determines whether a compromised container, an accident in a log file, or a public git push leaks them. Secrets management is not just about where credentials are stored; it is about ensuring they are never in a place where they should not be: source code, log lines, tool responses, environment variable dumps, or stack traces.

TL;DR

Inject secrets via environment variables in local and CI environments; use AWS Secrets Manager, HashiCorp Vault, or Kubernetes Secrets for production. Validate all secrets at startup with Zod — fail before the server accepts connections if required credentials are absent. Log presence, not value: DATABASE_URL=set(52 chars) not the URL itself. Never include secret values in MCP tool responses. Rotate credentials through the secrets manager without restarting the server — the application fetches fresh credentials from the manager on a schedule or on receipt of a rotation event.

The four secrets injection patterns

Each injection pattern has different security and operability characteristics. Most production servers combine two: environment variables for simple config, a secrets manager for credentials.

PatternBest forRisk
Environment variables (plain)Local dev, CI, simple deploysVisible in ps auxe, may appear in crash dumps, require manual rotation
Environment variables from secrets manager at deploy timeECS task definitions, Heroku config varsSecret fetched once at deploy — stale after rotation until redeploy
Secrets manager SDK fetched at startupAWS Secrets Manager, VaultRequires IAM role / Vault token; initial bootstrap secret still needed
Kubernetes Secret mounted as fileKubernetes deploymentsKubelet keeps file in sync with Secret — rotation propagates without restart

Zod validation at startup

Validate all required secrets in createDeps() before any connections open. A server that starts without its database password will fail every tool call at runtime — fail-fast at startup is better. See MCP server configuration management for the full Zod schema pattern.

// config.ts — Zod schema for secrets
import { z } from 'zod';

const SecretSchema = z.object({
  DATABASE_URL:        z.string().min(10),
  REDIS_URL:           z.string().min(10),
  SEARCH_API_KEY:      z.string().min(16),
  NOTIFICATION_SECRET: z.string().min(16),
  JWT_PUBLIC_KEY:      z.string().startsWith('-----BEGIN PUBLIC KEY-----'),
}).strict();

export type Config = z.infer<typeof SecretSchema>;

export function parseConfig(env: NodeJS.ProcessEnv = process.env): Config {
  const result = SecretSchema.safeParse(env);
  if (!result.success) {
    // Log which keys are missing — never log the actual values
    const missing = result.error.issues.map(i => i.path.join('.')).join(', ');
    throw new Error(`Missing or invalid secrets: ${missing}`);
  }
  return result.data;
}

The schema uses .strict() so unexpected keys (typos, leftover test credentials) cause a validation error. safeParse returns the error object instead of throwing — you can log a sanitised error message listing which keys failed without ever touching the values.

Logging presence, not value

The most common secret leak vector in production is application logging. A startup log that prints process.env, a crash report that includes the full config object, or an error message that echoes the connection string all expose secrets in log aggregators.

Log a summary — key name, length, and a presence flag — never the value:

// deps.ts — safe config startup log
function logConfigSummary(config: Config, logger: Logger): void {
  logger.info('config_loaded', {
    database_url: `set(${config.DATABASE_URL.length} chars)`,
    redis_url: `set(${config.REDIS_URL.length} chars)`,
    search_api_key: `set(${config.SEARCH_API_KEY.length} chars)`,
    notification_secret: `set(${config.NOTIFICATION_SECRET.length} chars)`,
    jwt_public_key: config.JWT_PUBLIC_KEY.startsWith('-----BEGIN') ? 'present' : 'MISSING',
  });
}

Also redact secrets from the DATABASE_URL before logging it — connection strings embed credentials inline:

function redactConnectionString(url: string): string {
  try {
    const u = new URL(url);
    if (u.password) u.password = '[REDACTED]';
    if (u.username) u.username = '[REDACTED]';
    return u.toString();
  } catch {
    return '[unparseable connection string]';
  }
}

AWS Secrets Manager integration

AWS Secrets Manager stores secrets as versioned JSON documents and provides automatic rotation for supported databases. The SDK fetches the secret at startup and, optionally, on a timer for rotation-aware patterns.

// secrets.ts — fetch from AWS Secrets Manager at startup
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

const smClient = new SecretsManagerClient({ region: process.env.AWS_REGION ?? 'us-east-1' });

export async function fetchSecrets(secretArn: string): Promise<Record<string, string>> {
  const cmd = new GetSecretValueCommand({ SecretId: secretArn });
  const resp = await smClient.send(cmd);
  if (!resp.SecretString) throw new Error(`Secret ${secretArn} has no string value`);
  return JSON.parse(resp.SecretString) as Record<string, string>;
}

// deps.ts — integrate with createDeps
export async function createDeps(): Promise<Deps> {
  const secretsArn = process.env.SECRETS_ARN;
  const envSecrets = secretsArn ? await fetchSecrets(secretsArn) : {};

  // Merge: Secrets Manager values override environment variables
  const merged = { ...process.env, ...envSecrets };
  const config = parseConfig(merged);
  logConfigSummary(config, logger);
  // ... rest of createDeps
}

The IAM role for the ECS task or EC2 instance grants secretsmanager:GetSecretValue on the specific secret ARN — no long-lived access key needed. The EC2 metadata endpoint provides the temporary credential automatically.

HashiCorp Vault integration

Vault's dynamic secrets feature generates short-lived database credentials on demand. Each application instance gets a unique username/password pair that Vault revokes automatically when the lease expires. If a credential leaks, the blast radius is limited to the lease window.

// vault.ts — fetch dynamic database credential from Vault
import { createHash } from 'crypto';

interface VaultDbCred {
  username: string;
  password: string;
  lease_duration: number; // seconds
}

export async function fetchVaultDbCred(
  vaultAddr: string,
  vaultToken: string,
  mountPath: string,
  role: string,
): Promise<VaultDbCred> {
  const res = await fetch(`${vaultAddr}/v1/${mountPath}/creds/${role}`, {
    headers: { 'X-Vault-Token': vaultToken },
  });
  if (!res.ok) throw new Error(`Vault creds fetch failed: ${res.status}`);
  const body = await res.json() as { data: VaultDbCred; lease_duration: number };
  return { ...body.data, lease_duration: body.lease_duration };
}

// Renew credential on a schedule — half the lease window before expiry
export async function startCredentialRenewer(
  deps: Deps,
  fetchFn: () => Promise<VaultDbCred>,
  logger: Logger,
): Promise<void> {
  const cred = await fetchFn();
  const renewMs = (cred.lease_duration / 2) * 1000;
  setTimeout(async () => {
    try {
      const newCred = await fetchFn();
      // Reconnect pool with new credentials
      await deps.db.end();
      deps.db = new Pool({ connectionString: buildConnectionString(newCred) });
      logger.info('credential_renewed', { lease_duration: newCred.lease_duration });
      startCredentialRenewer(deps, fetchFn, logger); // schedule next renewal
    } catch (err) {
      logger.error('credential_renewal_failed', { error: String(err) });
    }
  }, renewMs);
}

Kubernetes Secrets mounted as files

Kubernetes Secrets mounted as files are the most rotation-friendly pattern: the kubelet updates the file in the pod's filesystem when the Secret changes, without a pod restart. The application polls or watches the file to pick up new values.

# pod spec — mount secret as file
spec:
  containers:
    - name: mcp-server
      image: your-registry/mcp-server:latest
      volumeMounts:
        - name: db-creds
          mountPath: /run/secrets/db
          readOnly: true
  volumes:
    - name: db-creds
      secret:
        secretName: mcp-server-db-creds
        items:
          - key: DATABASE_URL
            path: DATABASE_URL
// secrets-watcher.ts — watch mounted secret file for rotation
import { watch } from 'fs';

export function watchSecretFile(
  filePath: string,
  onRotation: (newValue: string) => Promise<void>,
  logger: Logger,
): void {
  watch(filePath, async (eventType) => {
    if (eventType === 'change') {
      try {
        const { readFileSync } = await import('fs');
        const newValue = readFileSync(filePath, 'utf8').trim();
        await onRotation(newValue);
        logger.info('secret_rotated', { file: filePath, length: newValue.length });
      } catch (err) {
        logger.error('secret_rotation_failed', { file: filePath, error: String(err) });
      }
    }
  });
}

Preventing secret leakage in tool responses

MCP tool responses are returned to the calling LLM and may be logged by the client, stored in conversation history, or included in downstream API calls. A tool that accidentally includes a secret in its response content has effectively exfiltrated that secret to every system that processes the conversation.

Common leakage patterns to audit:

// error-handler.ts — sanitise errors before returning to MCP client
function sanitiseError(err: unknown): string {
  if (err instanceof Error) {
    // Strip potential connection strings from postgres/mysql errors
    return err.message
      .replace(/postgresql:\/\/[^@]+@[^\s]+/g, 'postgresql://[REDACTED]')
      .replace(/mysql:\/\/[^@]+@[^\s]+/g, 'mysql://[REDACTED]')
      .replace(/redis:\/\/:?[^@]*@[^\s]+/g, 'redis://[REDACTED]');
  }
  return 'unexpected error';
}

// In tool handler:
try {
  // ... tool logic
} catch (err) {
  return {
    content: [{ type: 'text', text: sanitiseError(err) }],
    isError: true,
  };
}

Monitoring and AliveMCP integration

Secrets management failures are silent: the server starts, appears healthy, and fails at the first tool call that requires the missing or rotated credential. AliveMCP's transport-layer probe confirms the server accepts connections but does not distinguish a healthy server from one with a stale credential that will fail on first use.

Include credential health in the health_check MCP tool. Check that each secret-backed connection can execute a lightweight query — database ping, Redis ping, a lightweight search API call — and return isError: true if any credential has expired or been revoked. This gives AliveMCP's synthetic probe a way to detect rotation failures before they surface as user-facing errors. See MCP Server Resilience and Configurability Guide for how secret validation integrates with the broader config and startup sequence.