Topic: mcp server secrets management

MCP server secrets management — how to handle API keys without leaking them

Of the 101 MCP servers in the SkillAudit April 2026 corpus, 38 have credential findings. The most common: hardcoded API keys in source files, environment variable echoes in tool responses, and .env files committed to the repository tree. All of these are preventable. Secrets management in MCP servers has one rule that covers most failures: read secrets at startup into typed configuration objects, never touch process.env inside a tool handler.

TL;DR

Safe secrets management for MCP servers: read environment variables once at startup, store in a typed config object, pass only what each handler needs. Never read process.env inside a tool handler. Never log or return environment variables. Never commit .env files with real secrets. If you're serving a multi-tenant deployment, use a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault) rather than environment variables for production secrets. OAuth delegation is the cleanest solution when the upstream API supports it — zero secrets in the server entirely.

Why credentials are the highest-blast-radius finding class

All six SkillAudit grading axes can produce HIGH findings, but credentials is the only axis whose blast radius is unbounded by the server's host process. When an SSRF finding is exploited, the attacker can reach network targets the server can see. When a command injection finding is exploited, the attacker can execute commands with the server process's permissions. Both of those are bounded by the server's network and OS context.

When a credential finding is exploited, the attacker gains the secret itself — and that secret travels with every downstream use. A leaked OpenAI API key can generate API calls that bill the key owner anywhere OpenAI's API is reachable, indefinitely, until the key is revoked. A leaked GitHub PAT can access every repository the token owner can see. A leaked Stripe live key can initiate financial transactions. The blast radius is determined by the credential's access scope, not by the server's network context.

The full breakdown of credential findings across the corpus — 64 hardcoded secrets in 18 repos, 13 environment echoes in 7 repos, 44 committed .env files in 28 repos — is documented in the anatomy of a credential leak post. This page covers the prevention architecture.

Anti-pattern 1: Hardcoded secrets in source

The most obvious pattern and the hardest to excuse: a real API key, PAT, or shared secret embedded as a string literal in the source code. Once committed, the secret lives in git history indefinitely — rotating the key and removing it from the current source doesn't remove it from past commits.

// BAD — hardcoded secret
const openai = new OpenAI({ apiKey: 'sk-proj-...' });

// GOOD — read from environment at startup
const config = {
  openaiApiKey: process.env.OPENAI_API_KEY ?? (() => {
    throw new Error('OPENAI_API_KEY is required');
  })()
};
const openai = new OpenAI({ apiKey: config.openaiApiKey });

The immediate rotation steps if you find a hardcoded secret: revoke the key first (before removing it from code), then remove it from source, then use git filter-repo or BFG Repo Cleaner to scrub it from git history, then rotate again to a new key value. The reason for rotating before scrubbing is that the secret may have been exposed to anyone who cloned the repo before the scrub.

Anti-pattern 2: Reading process.env inside tool handlers

More subtle than hardcoding, and the pattern SkillAudit flags as HIGH when found in production-path code: reading environment variables inside a tool handler function and including the result in the return value or log output.

// BAD — reading env inside handler, result may reach model context
server.tool('debug_info', {}, async () => {
  return {
    content: [{
      type: 'text',
      text: JSON.stringify({
        env: process.env,  // ALL environment variables returned to model
        config: {
          apiKey: process.env.API_KEY  // specific secret returned to model
        }
      })
    }]
  };
});

// GOOD — config read at startup, never echoed to tool output
const config = loadConfig(); // reads env vars once, at module load

server.tool('debug_info', {}, async () => {
  return {
    content: [{
      type: 'text',
      text: JSON.stringify({
        server_version: '1.2.0',
        tools_registered: server.listTools().length
        // no secrets, no env dump
      })
    }]
  };
});

The MCP-specific amplifier: anything a tool handler returns becomes part of the model's context window, and the model's context window is logged by every agent orchestration layer. A credential that reaches a tool response ends up in conversation transcripts, LLM provider logs, and any downstream storage the user's setup includes — all without any indication to the user that a secret was leaked.

Anti-pattern 3: .env files committed to the repository

A .env file with real values committed to the repository is effectively a hardcoded secret — the file lives in git history with the same permanence as a string literal in source. Even files named .env.local, .env.test, or .env.example are flagged if they contain real-looking values rather than clearly fake placeholder values like YOUR_API_KEY_HERE.

The correct practice: commit only .env.example files with clearly placeholder values, add .env, .env.local, .env.test, and .env.production to .gitignore unconditionally, and document in your README what environment variables the server requires. A well-structured .env.example with placeholder values and inline comments explaining what each variable is for serves as documentation without creating a leakage risk.

Anti-pattern 4: Over-collection of credentials at startup

Requesting or accepting more credentials than the server's handlers actually use. This matters most in multi-server deployments where the same configuration secret is shared across multiple servers — an installer who provides their GitHub PAT to configure one server may not realize it's being stored by other servers in the same installation that don't need it.

The minimum-credential principle: each server should read only the secrets its registered handlers actually use, validate that they're present and have the expected format at startup, and fail fast with a clear error message rather than silently accepting an oversupply of secrets that creates unnecessary leakage surface.

Safe pattern 1: Environment variables read at startup

For most MCP servers running in a user's local environment, environment variables are the right mechanism. The pattern is simple: read them once at module initialization (not inside handlers), validate their presence and format with a typed config schema, and export a config object that handlers use via a module-level import.

// config.ts — read once at startup
import { z } from 'zod';

const ConfigSchema = z.object({
  githubPat: z.string().min(40),   // GitHub PAT
  openaiApiKey: z.string().startsWith('sk-'),  // OpenAI key format
  maxRequestSize: z.coerce.number().default(10_000),
});

export const config = ConfigSchema.parse({
  githubPat: process.env.GITHUB_PAT,
  openaiApiKey: process.env.OPENAI_API_KEY,
  maxRequestSize: process.env.MAX_REQUEST_SIZE,
});

// server.ts — import config, never touch process.env directly
import { config } from './config.js';
import { Octokit } from 'octokit';

const octokit = new Octokit({ auth: config.githubPat });
// handlers use octokit, never config.githubPat directly

Safe pattern 2: Secrets managers for production deployments

For MCP servers deployed in production environments (running in containers, on servers, or in managed agent infrastructure), environment variables are often not the right mechanism — they're visible in the process environment of any code running in the same container, and they're typically passed through deployment pipelines in plaintext. Secrets managers solve these problems by storing secrets encrypted at rest, delivering them to the running process on demand, and providing audit logs of every secret access.

The pattern with AWS Secrets Manager:

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

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

async function getApiKey(): Promise<string> {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: 'mcp-server/api-key' })
  );
  return response.SecretString!;
}

// Load at startup, not per-request
const apiKey = await getApiKey();

The key discipline: load the secret at startup into a local variable, not on every tool call. Per-request secrets manager calls add latency and can cause rate limiting at scale.

Safe pattern 3: OAuth delegation

The cleanest secrets management architecture is one where the MCP server holds no secrets at all — it delegates authentication entirely to OAuth flows. Instead of storing a GitHub PAT, the server initiates the OAuth device flow, receives a short-lived access token scoped to exactly what it needs, and uses that token for the current session only.

This is the architecture the MCP specification was designed to support. OAuth delegation means: no credential to steal from the server process, no credential to rotate, no credential to appear in git history. The tradeoff is implementation complexity — OAuth requires handling the authorization flow, token refresh, and scope negotiation, which is more complex than reading an environment variable.

For authors building servers that will be distributed to many users, OAuth is the right default. For personal servers used by a single user in their own environment, environment variables are simpler and adequate.

How SkillAudit grades secrets management

The credentials axis in the SkillAudit grading rubric scores secrets management directly. HIGH findings block install for production use and appear as red items in the audit report. WARN findings are flagged for manual review but don't block install. The grading tiers:

See the methodology page for the complete credentials-axis rubric, and the anatomy of a credential leak post for the full corpus breakdown across all four patterns.

Further reading