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:
- Token echoed in error response —
return { text: `Failed: ${err.message}` }whereerr.messagecan contain the token value. Useerr.codeor a generic message instead; never passerritself into a template string that goes into the tool response. - Token in log statement that flows to stdout — MCP servers running over stdio have their stdout captured by the calling agent. A
console.log(`Calling vendor with token ${token}`)statement means the token appears in the agent's conversation context. Use a structured logger with aredactlist, or strip env-var-shaped strings from log output before they reach stdout. - Token in URL parameter — some vendor SDKs accept the API key as a URL query parameter (
?api_key=xxx). These appear in server access logs, error messages, and any fetch error that includes the URL. Always use header-based authentication (Authorization: Bearer xxx) so the token doesn't appear in URLs.
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.