Topic: mcp server secrets vault

MCP server secrets vault — managing credentials without hardcoding or env-var leakage

Of the 101 MCP servers in the SkillAudit corpus, 38 carry credentials-axis findings: 64 hardcoded API keys across 18 repos, 13 env-var echoes to stdout across 7 repos, and 44 committed .env files across 28 repos. The root cause is almost always the same: credentials are read from process.env at startup and stored on the server object, where they are one tool-handler bug or prompt-injection payload away from being echoed back to the LLM. Secrets management in an MCP server is not the same as secrets management in a conventional API server — the LLM conversation context is an output channel that none of your existing secret-detection tooling monitors.

Why env-var reading is not enough

Most MCP server authors understand "don't hardcode secrets" and read from process.env instead. This solves the committed-secret problem but introduces a new one: the secret now lives on the running server object, accessible to every tool handler. Consider the typical initialization pattern:

// common but dangerous
const apiClient = new VendorClient({ token: process.env.VENDOR_API_TOKEN });

server.tool('search', async ({ query }) => {
  try {
    const result = await apiClient.search(query);
    return { content: [{ type: 'text', text: JSON.stringify(result) }] };
  } catch (err) {
    // the token is now potentially in the error message
    return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
  }
});

The SSRF risk: if query is a crafted string that causes apiClient.search() to make an HTTP request to an attacker-controlled endpoint, the request will carry the Authorization: Bearer VENDOR_API_TOKEN header. The prompt-injection risk: if an attacker gets err.message to include the token value (e.g. by inducing a validation error that echoes the input), the secret returns inside the tool response. These are not theoretical — the SkillAudit corpus has examples of both patterns from vendor-official releases.

Pattern 1 — runtime injection with per-call scope

The safest pattern reads credentials at call time from a well-defined source rather than at startup. This limits the window in which the secret exists in memory and allows per-caller credential isolation.

import { Server } from '@modelcontextprotocol/sdk/server/index.js';

const server = new Server({ name: 'vendor-mcp', version: '1.0.0' });

server.tool('search', async ({ query }, { meta }) => {
  // Credentials resolved per-call from caller context or env
  // Never stored on the server object
  const token = resolveToken(meta);
  if (!token) {
    return { content: [{ type: 'text', text: 'No API token configured.' }] };
  }

  // Create a short-lived client; don't reuse across calls
  const client = new VendorClient({ token });
  const result = await client.search(sanitize(query));

  // Return only the data the caller needs — never echo the token
  return { content: [{ type: 'text', text: result.summary }] };
});

function resolveToken(meta) {
  // Priority: per-caller token from MCP auth header > env var
  // Never fall back to a hardcoded default
  return meta?.credentials?.vendor_token ?? process.env.VENDOR_API_TOKEN ?? null;
}

The key changes: no global apiClient object, credentials resolved per-call so they're not accessible outside the call frame, and the return value is narrowed to only the data the caller needs (never the full API response object which may contain auth headers or token echoes).

Pattern 2 — external vault fetch at runtime

For team deployments where the MCP server runs as a shared HTTP-transport process (multiple agents calling the same server), per-caller credential isolation requires an external credential store that the server queries at runtime rather than reading from environment variables.

// vault.ts — thin wrapper around your secrets store
// Works with HashiCorp Vault, AWS Secrets Manager, 1Password Connect, etc.

export async function getSecret(name: string): Promise<string | null> {
  // Using AWS Secrets Manager as an example
  const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
  try {
    const cmd = new GetSecretValueCommand({ SecretId: `mcp-server/${name}` });
    const response = await client.send(cmd);
    return response.SecretString ?? null;
  } catch {
    return null;
  }
}

// In tool handler:
server.tool('search', async ({ query }) => {
  const token = await getSecret('vendor-api-token');
  if (!token) throw new Error('Secret not found — check vault configuration.');

  // token is in scope only for this call, not stored anywhere
  const client = new VendorClient({ token });
  return { content: [{ type: 'text', text: (await client.search(query)).summary }] };
});

Vault fetch adds latency (~20–80ms depending on provider and caching). For high-frequency tool calls, add a short TTL in-process cache (30–60 seconds) using a Map<string, { value: string; expires: number }> keyed on secret name. Never cache beyond 5 minutes; secret rotations need to propagate quickly enough to matter.

Pattern 3 — per-caller credential isolation (Team plan pattern)

The highest-security pattern for multi-tenant HTTP-transport deployments: each caller presents their own credential via the MCP auth layer, and the server never holds a shared token at all. This eliminates the "one SSRF exposes the team token" blast-radius problem entirely.

// In an HTTP-transport MCP server with caller authentication:

server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
  // Each caller's agent passes their own vendor token in the MCP auth header
  const callerToken = context.authInfo?.credentials?.vendor_token;

  if (!callerToken) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Vendor API token required. Configure it in your agent\'s MCP auth settings.' }]
    };
  }

  // Validate token shape before use (don't pass attacker-controlled strings to vendor API)
  if (!/^[A-Za-z0-9_-]{20,80}$/.test(callerToken)) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Invalid token format.' }]
    };
  }

  // Per-caller client — no shared token
  const client = new VendorClient({ token: callerToken });
  // ...
});

The tradeoff: each agent user must configure their own vendor API token, which adds setup friction compared to a shared-token model. For team deployments where all agents share the same vendor account, the vault pattern (Pattern 2) is a better fit. Per-caller isolation is most valuable when different agents operate under different security contexts or different tenant-level vendor accounts.

What to avoid

Three patterns that SkillAudit consistently flags as HIGH findings:

Run a SkillAudit on your MCP server

The credentials axis of the SkillAudit engine checks all three patterns above: regex-based hardcoded-secret detection across the full repo tree, AST-based taint analysis from process.env reads to tool-handler return values, and .env file tracking via git history. Paste your GitHub URL at skillaudit.dev — results in 60 seconds, public badge included.