Back to blog
·7 min read·BitAtlas Team

MCP Server Secret Injection Patterns

How to safely inject API keys, tokens, and credentials into MCP servers at runtime without leaking them to agents or logs.

MCP serversecret injectionenvironment variablesvaultruntime secretsagent security

When you stand up an MCP server, you immediately face a question that sounds simple but has subtle teeth: where do the credentials go? Your server needs a database password, an API key for a third-party service, maybe a signing secret. The agent sitting on the other side of the connection should never see those values—yet they have to get into the process somehow.

This post covers the main patterns teams use to solve that problem, their tradeoffs, and the failure modes to avoid.

Why Secret Injection Is Harder Than It Looks

The naive approach—set an environment variable, read it in your handler—works fine in development. It breaks down in production for a few reasons:

  • Agents can read env vars through tools. If your MCP server exposes a run_command or read_file tool, a sufficiently capable agent (or a prompt-injected one) can exfiltrate process.env unless you explicitly scope what it can access.
  • Logs capture more than you think. Request logging, error tracing, and framework debug modes regularly print entire request objects—including headers, which is where tokens often live.
  • Container restarts reload secrets. Baking secrets into a Docker image or a static config file means every old image layer is a potential leak vector.

The goal is a runtime injection model where secrets exist in process memory only for the duration they are needed, are never serialized into logs, and are never visible to the agent's tool output.

Pattern 1: Sidecar Vault Agent

HashiCorp Vault (and compatible alternatives like Infisical, Doppler, or AWS Secrets Manager with the agent proxy) provides a sidecar model where a separate lightweight process authenticates to the vault, retrieves secrets, and writes them to a shared in-memory tmpfs mount. Your MCP server reads from that mount at startup.

/run/secrets/
  db_password       ← vault-agent writes here
  openai_api_key    ← vault-agent writes here

Your Node or Python process does:

import { readFileSync } from "fs";
const DB_PASSWORD = readFileSync("/run/secrets/db_password", "utf8").trim();

The advantages:

  • The secret never appears in environment variables or the container's initial config.
  • Vault agent handles lease renewal; your server always has a valid credential.
  • The tmpfs mount disappears on container stop—nothing persists to disk.

The cost is operational complexity: you need a vault cluster, Kubernetes service account binding (or equivalent), and the vault-agent sidecar container configured correctly.

Pattern 2: Runtime Fetch with Short-Lived Tokens

Instead of secrets living in the process at startup, fetch them on demand using workload identity. AWS IRSA, GCP Workload Identity Federation, and Azure Managed Identity all let a pod or VM prove who it is to the cloud provider, then exchange that proof for a short-lived access token. The application never handles a static long-term credential.

The flow for an MCP server on EKS:

  1. The pod has an IAM role annotation. AWS injects a projected service account token into /var/run/secrets/eks.amazonaws.com/serviceaccount/token.
  2. Your server calls sts:AssumeRoleWithWebIdentity with that token to get a 15-minute AWS credential.
  3. Use those credentials to call Secrets Manager: secretsmanager:GetSecretValue.
  4. Cache the plaintext value in memory, tagged with expiry. Refresh before expiry.
async function getSecret(secretName: string): Promise<string> {
  const client = new SecretsManagerClient({ region: "eu-west-1" });
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretName })
  );
  return response.SecretString!;
}

Short-lived credentials matter because credential theft has a bounded blast radius: a stolen 15-minute token expires before most attackers can operationalize it.

Pattern 3: Sealed Config Injection at Deploy Time

Some teams want simplicity over dynamism. The sealed-config pattern encrypts secrets with a key that lives in a hardware security module (HSM) or a managed KMS. At deploy time, the CI/CD pipeline fetches a sealed config blob and passes it to the container as a single environment variable or mounted secret. The container unseals on startup using its instance identity.

# At deploy time (CI/CD)
SEALED_CONFIG=$(aws kms encrypt \
  --key-id alias/mcp-server-key \
  --plaintext "$(cat secrets.json)" \
  --query CiphertextBlob \
  --output text)

# Container receives SEALED_CONFIG env var
# On startup, server calls kms:Decrypt with its IAM role

The benefit: no vault sidecar needed. The secret material in the env var is useless without the KMS key, and the KMS key is access-controlled by IAM. Logs that capture SEALED_CONFIG capture only ciphertext.

The limitation: secrets are baked at deploy time. If you need to rotate a credential, you must redeploy. Acceptable for low-rotation secrets (like a signing key), awkward for high-rotation ones (like OAuth tokens).

Preventing Agent-Side Leakage

Even with good injection, you need to ensure the agent cannot extract secrets through the tools your MCP server exposes. Several structural rules help:

Never put secrets in tool output. If a tool returns database query results, make sure the connection string is not included in any error path. Catch exceptions before they propagate as raw strings.

Sanitize error messages. A common leak: Error: ECONNREFUSED connecting to postgres://user:password@host:5432/db. Use a connection pool library that redacts credentials from error objects, or wrap all DB errors in a sanitized exception type.

Restrict what env vars tools can read. If you expose any shell-execution or file-read tools, explicitly denylist the env vars your server uses for secrets:

function sanitizeEnv(env: NodeJS.ProcessEnv): Record<string, string> {
  const BLOCKED = new Set(["DB_PASSWORD", "API_KEY", "SIGNING_SECRET"]);
  return Object.fromEntries(
    Object.entries(env).filter(([k]) => !BLOCKED.has(k))
  );
}

Log secret names, not values. When tracing tool calls, log "Loaded secret: db_password" not the value. Structured loggers with redaction middleware (like pino's redact option) can enforce this automatically.

Audit What Gets Into Traces

Distributed tracing systems—Jaeger, Honeycomb, Datadog APM—regularly capture request spans with full attribute bags. If your MCP server adds span attributes naively, you may be shipping secrets to a third-party observability platform.

Audit your tracing instrumentation:

// Dangerous
span.setAttribute("db.connection", connectionString);

// Safe
span.setAttribute("db.host", dbHost);
span.setAttribute("db.name", dbName);
// connection string stays out

If you use OpenTelemetry, the @opentelemetry/sdk-node package supports attribute filtering via SpanProcessor. Add a processor that strips any attribute whose key matches a blocklist pattern before export.

Rotation Without Restarts

Long-running MCP servers create a rotation problem: how do you get the new credential into the process without restarting? Two approaches work well:

Signal-based reload. On SIGHUP, the server re-reads from the vault-agent mount or re-fetches from Secrets Manager. The connection pool drains old connections and establishes new ones with the updated credential. This is a standard Unix pattern and most vault agents support triggering it automatically after a secret renewal.

Credential abstraction layer. Rather than using a raw string credential, wrap it in an object with a get() method that checks validity before returning:

class ManagedSecret {
  private value: string = "";
  private expiresAt: number = 0;

  async get(): Promise<string> {
    if (Date.now() > this.expiresAt - 30_000) {
      await this.refresh();
    }
    return this.value;
  }

  private async refresh() {
    // fetch from secrets manager
  }
}

Callers always call await secret.get() rather than reading a module-level variable. The secret rotates transparently behind the interface.

Choosing a Pattern

The right pattern depends on your operational maturity:

ScenarioRecommended pattern
Small team, no existing vaultSealed config + KMS
Kubernetes-native workloadsWorkload identity + Secrets Manager
Multi-cloud, secrets-heavyVault sidecar agent
Frequent credential rotationRuntime fetch with short TTL

The common thread across all of them: secrets should never appear in plain text in environment variables that tools can read, in log lines, or in trace spans shipped to external systems. The agent can do its job without ever knowing the underlying credentials—that separation is what makes MCP server deployments defensible at scale.


BitAtlas stores agent credentials using zero-knowledge architecture so that even the infrastructure layer never sees your plaintext secrets. Learn more about how it works.

Encrypt your agent's data today

BitAtlas gives your AI agents AES-256-GCM encrypted storage with zero-knowledge guarantees. Free tier, no credit card required.