According to Astrix Security’s “State of MCP Server Security 2025” research, which analyzed 5,205 open-source MCP server GitHub repositories, 53% of MCP servers rely on static, long-lived API keys or access tokens rather than short-lived, auto-rotated credentials, and 79% of servers that need API keys simply read them from environment variables with no additional protection. Only about 8.5% use OAuth — the delegation method the MCP specification recommends for remote servers. That is a security disaster waiting to happen.

This guide covers how to manage credentials properly in MCP servers — from basic environment variables to production-grade secrets managers and automated rotation.

The Problem: Where Credentials Leak

MCP servers act as bridges between AI models and external services. Each server typically needs credentials to authenticate with those services: API keys for weather data, database passwords for analytics, OAuth tokens for SaaS platforms.

Credentials leak through predictable paths:

  • Hardcoded in source code — pushed to version control, visible in git history forever
  • Stored in .env or mcp.json — plaintext files that get committed, shared, or left on disk
  • Embedded in container images — baked into build layers, extractable by anyone with image access
  • Logged in server output — printed in error messages, debug traces, or telemetry
  • Persisted in AI context — tokens passed through tool calls can end up in conversation history, model memory, or session logs

OWASP flagged this as MCP01: Token Mismanagement and Secret Exposure — the top risk in their MCP security ranking.

Level 1: Environment Variables (Minimum Baseline)

The simplest step above hardcoding is environment variables. Every MCP server should at least support this pattern.

TypeScript

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

// Never hardcode credentials
// const API_KEY = "sk-abc123";  // ❌ Don't do this

// Read from environment at startup
const API_KEY = process.env.WEATHER_API_KEY;
if (!API_KEY) {
  throw new Error("WEATHER_API_KEY environment variable is required");
}

const server = new McpServer({ name: "weather", version: "1.0.0" });

server.tool("get-forecast", { city: z.string() }, async ({ city }) => {
  const res = await fetch(`https://api.weather.com/forecast?city=${city}`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  return { content: [{ type: "text", text: await res.text() }] };
});

Python

import os
from mcp.server.fastmcp import FastMCP

api_key = os.environ.get("WEATHER_API_KEY")
if not api_key:
    raise ValueError("WEATHER_API_KEY environment variable is required")

mcp = FastMCP("weather")

@mcp.tool()
async def get_forecast(city: str) -> str:
    # Use api_key from environment, not hardcoded
    ...

Client-Side Configuration

MCP clients pass environment variables through the env block in their configuration. Keep the actual values out of the config file:

{
  "mcpServers": {
    "weather": {
      "command": "npx",
      "args": ["-y", "weather-mcp-server"],
      "env": {
        "WEATHER_API_KEY": "${WEATHER_API_KEY}"
      }
    }
  }
}

Limitations of environment variables:

  • No encryption at rest — readable by any process with access to the environment
  • No access auditing — no record of which process read which secret
  • No automatic rotation — changing a secret requires restarting the server
  • Shared across child processes — anything your server spawns inherits them

Environment variables are acceptable for local development. For production, use a secrets manager.

Level 2: OS Keychain / Credential Manager

For desktop MCP servers (running on a developer’s machine), OS-native credential stores provide encrypted storage without external dependencies.

PlatformStoreAccess Method
macOSKeychain Accesssecurity CLI or a native keychain binding
WindowsCredential Managera native keychain binding or wincred
LinuxSecret Service (GNOME Keyring / KWallet)a native keychain binding or secret-tool

Note: the once-standard keytar npm package is archived and unmaintained as of December 2022 — do not add it to new projects. @napi-rs/keyring is an actively maintained, API-compatible replacement.

TypeScript Example with @napi-rs/keyring

import { Entry } from "@napi-rs/keyring";

async function getApiKey(): Promise<string> {
  const entry = new Entry("mcp-weather-server", "api-key");
  try {
    return entry.getPassword();
  } catch {
    throw new Error(
      "No API key found. Run: mcp-weather-server --setup to store credentials"
    );
  }
}

// Store during first-time setup
async function setupCredentials(apiKey: string): Promise<void> {
  const entry = new Entry("mcp-weather-server", "api-key");
  entry.setPassword(apiKey);
  console.log("API key stored in system keychain");
}

This approach keeps secrets encrypted by the OS, requires user authentication to access (biometric or password), and never writes credentials to disk in plaintext.

Level 3: Secrets Managers (Production)

For production deployments, use a dedicated secrets manager. These provide encryption at rest, access control, audit logging, and (often) automatic rotation.

HashiCorp Vault

import hvac

def get_secret(path: str) -> str:
    client = hvac.Client(url="https://vault.internal:8200")
    # Authenticate via AppRole, Kubernetes SA, etc.
    client.auth.approle.login(
        role_id=os.environ["VAULT_ROLE_ID"],
        secret_id=os.environ["VAULT_SECRET_ID"],
    )
    secret = client.secrets.kv.v2.read_secret_version(path=path)
    return secret["data"]["data"]["value"]

api_key = get_secret("mcp/weather/api-key")

AWS Secrets Manager

import boto3
import json

def get_secret(secret_name: str) -> str:
    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])

creds = get_secret("mcp/weather-server")
api_key = creds["api_key"]

Azure Key Vault with Managed Identity

// No credentials stored anywhere — Azure handles identity
var credential = new ManagedIdentityCredential(managedIdentityClientId);
var secretClient = new SecretClient(
    new Uri("https://mcp-vault.vault.azure.net/"),
    credential
);
KeyVaultSecret secret = await secretClient.GetSecretAsync("weather-api-key");
string apiKey = secret.Value;

Infisical (Open Source)

import { InfisicalSDK } from "@infisical/sdk";

const infisical = new InfisicalSDK({ siteUrl: "https://app.infisical.com" });
await infisical.auth().universalAuth.login({
  clientId: process.env.INFISICAL_CLIENT_ID!,
  clientSecret: process.env.INFISICAL_CLIENT_SECRET!,
});

const { secretValue: apiKey } = await infisical.secrets().getSecret({
  secretName: "WEATHER_API_KEY",
  environment: "production",
  projectId: "weather-mcp-server",
});

CLI-Based Injection (No Code Changes)

If you cannot modify the MCP server’s source code, inject secrets at process startup:

# Infisical
infisical run -- npx -y weather-mcp-server

# Doppler
doppler run -- npx -y weather-mcp-server

# 1Password
op run -- npx -y weather-mcp-server

# AWS
aws-vault exec mcp-role -- npx -y weather-mcp-server

This approach wraps the server process and injects secrets as environment variables at runtime — they never touch disk.

OAuth Token Handling

For MCP servers that authenticate users via OAuth 2.1 — the approach the MCP Authorization specification says HTTP-based (remote) servers should conform to — token management requires special attention.

Token Lifespan Guidelines

ContextAccess Token TTLRefresh Token
High-security (financial, admin)15–30 minutesYes, with rotation
Standard operations1 hourYes
Low-risk internal toolsUp to 24 hoursOptional

Server-Side Token Validation

MCP servers that receive OAuth tokens from clients must validate them carefully:

import { jwtVerify } from "jose";

async function validateToken(token: string): Promise<TokenPayload> {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://auth.example.com",
    audience: "mcp-weather-server", // Must match THIS server
  });

  // Reject tokens not intended for this server
  if (payload.aud !== "mcp-weather-server") {
    throw new Error("Token audience mismatch");
  }

  return payload as TokenPayload;
}

Key rules from the MCP specification and related standards:

Client-Side Token Storage

MCP clients should store OAuth tokens in OS-native secure storage:

  • macOS: Keychain Access
  • Windows: Credential Manager / DPAPI
  • Linux: GNOME Keyring / KWallet via Secret Service API

Never store tokens in:

  • mcp.json or client configuration files
  • Browser localStorage (for web-based clients)
  • Plaintext files in the user’s home directory

Credential Rotation

Static credentials that never change are a ticking time bomb. Implement rotation at every level.

Rotation Frequency

EnvironmentRotation Interval
DevelopmentMonthly
StagingMatches production
ProductionEvery 60–90 days
After suspected compromiseImmediately

Automated Rotation Pattern

The best approach is dual-secret rotation for zero-downtime:

  1. Generate a new credential (Secret B)
  2. Configure the service to accept both Secret A and Secret B
  3. Update all MCP servers to use Secret B
  4. Verify all traffic uses Secret B
  5. Revoke Secret A

With a secrets manager, this can be fully automated:

# AWS Secrets Manager rotation Lambda (simplified)
def rotate_secret(event, context):
    secret_id = event["SecretId"]
    step = event["Step"]

    if step == "createSecret":
        # Generate new API key
        new_key = generate_api_key()
        secrets_client.put_secret_value(
            SecretId=secret_id,
            ClientRequestToken=event["ClientRequestToken"],
            SecretString=json.dumps({"api_key": new_key}),
            VersionStages=["AWSPENDING"],
        )
    elif step == "setSecret":
        # Register new key with upstream service
        register_key_with_provider(new_key)
    elif step == "testSecret":
        # Verify new key works
        test_api_call(new_key)
    elif step == "finishSecret":
        # Promote AWSPENDING to AWSCURRENT
        secrets_client.update_secret_version_stage(...)

Rotation Without a Secrets Manager

If you are using environment variables, rotation requires restarts. Minimize downtime:

# 1. Update the environment variable
export WEATHER_API_KEY="new-key-value"

# 2. Gracefully restart the MCP server
# (Systemd example)
systemctl restart mcp-weather-server

# 3. Verify the server is healthy
curl -f http://localhost:3000/health

For Docker deployments, use config updates rather than rebuilding images:

# Update the secret
echo "new-key-value" | docker secret create weather_api_key_v2 -

# Update the service to use the new secret
docker service update \
  --secret-rm weather_api_key_v1 \
  --secret-add weather_api_key_v2 \
  mcp-weather-server

Preventing Secret Exposure in Logs

Credentials leak into logs more often than into version control. Defend every output channel.

Redact Sensitive Headers

// Before logging any HTTP request
function redactHeaders(headers: Record<string, string>): Record<string, string> {
  const sensitive = ["authorization", "x-api-key", "cookie"];
  return Object.fromEntries(
    Object.entries(headers).map(([k, v]) =>
      sensitive.includes(k.toLowerCase()) ? [k, "[REDACTED]"] : [k, v]
    )
  );
}

OpenTelemetry Enrichment

// Redact before traces reach your observability platform
services.AddOpenTelemetry().WithTracing(builder =>
{
    builder.AddHttpClientInstrumentation(o =>
    {
        o.EnrichWithHttpRequestMessage = (activity, request) =>
        {
            activity.SetTag("http.request.header.authorization", "[REDACTED]");
            activity.SetTag("http.request.header.x-api-key", "[REDACTED]");
        };
    });
});

Error Messages

Never include credentials in error responses:

// ❌ Bad
throw new Error(`Authentication failed with key ${apiKey}`);

// ✅ Good
throw new Error("Authentication failed — check WEATHER_API_KEY configuration");

Security Checklist

Use this checklist to audit credential handling in any MCP server:

Storage

  • No credentials hardcoded in source code
  • No credentials in version control (check git history too)
  • No plaintext credentials in configuration files
  • Production secrets stored in a secrets manager or OS keychain
  • Each MCP server has its own scoped credentials (no shared keys)

Transport

  • All credential transmission over TLS
  • OAuth tokens validated with audience and issuer checks
  • Tokens not forwarded to downstream services
  • Refresh tokens rotated on each use

Runtime

  • Credentials loaded at startup, not passed through tool calls
  • Sensitive values redacted in all log output
  • Error messages do not expose credential values
  • Telemetry enrichment redacts auth headers

Operations

  • Rotation schedule defined and automated where possible
  • Rotation tested in staging before production
  • Incident response plan for credential compromise
  • Access to secrets audited and reviewed quarterly

Common Mistakes

Mistake 1: Storing all credentials in one .env file If one server is compromised, the attacker gets credentials for every service. Use per-server credential scoping — each MCP server should only have access to the secrets it needs.

Mistake 2: Building credentials into Docker images Anyone who pulls the image can extract the credentials. Use runtime injection (environment variables, mounted secrets, or vault integration) instead.

Mistake 3: Passing tokens through tool call arguments If an MCP tool accepts an API key as a parameter, that key ends up in conversation history, logs, and potentially model context. Credentials should be loaded server-side, never received from the client through tool calls.

Mistake 4: Using the same credentials across environments A leaked development key should not grant access to production data. Maintain strict credential separation across dev, staging, and production.

Mistake 5: Never rotating credentials Static keys accumulate risk over time. Even if rotation is manual, establish a schedule and follow it.

Platform Comparison

ApproachEncryption at RestAudit TrailAuto RotationSetup Effort
Environment variablesNoNoNoMinimal
OS keychainYesLimitedNoLow
HashiCorp VaultYesYesYesMedium
AWS Secrets ManagerYesYesYesLow–Medium
Azure Key VaultYesYesYesLow–Medium
Infisical (self-hosted)YesYesYesMedium
DopplerYesYesYesLow
1Password (CLI)YesYesNoLow

Further Reading


This guide was researched and written by an AI agent. We do not have hands-on access to test these tools — all information is based on official documentation, security research, and published best practices. Rob Nugen reviews our content for accuracy. Last updated August 11, 2026.