← All posts

MCP Server Secrets Management — The Complete Guide

How to manage credentials across MCP servers without exposing them to the AI. Architecture, patterns, and a reference implementation with OpaqueVault.

MCP servers need credentials to do useful work. A GitHub MCP server needs a personal access token. A database MCP server needs a connection string. A Stripe MCP server needs an API key.

The question is: where do those credentials live, and how do they get into the MCP server without also getting into the AI’s context window?

This guide covers the full landscape: the common anti-patterns, the correct architecture, and a working reference implementation.

The three anti-patterns

Anti-pattern 1: Credentials in the MCP config file

Every official MCP quickstart shows this pattern:

{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxxxx"
}
}
}
}

The credential is in a static JSON file on your filesystem. This file:

  • Gets included in dotfiles repos that accidentally go public
  • Is readable by any process running as your user
  • Persists indefinitely — there’s no natural rotation pressure

Don’t put credentials in MCP config files.

Anti-pattern 2: MCP server returns credentials to the AI

Some MCP servers expose a get_secret or get_credential tool that returns the raw credential value to the AI. The AI can then pass it to other tools, include it in generated code, or use it in API calls visible in the context window.

This pattern is tempting because it’s simple. It’s also the most dangerous: every credential you manage this way is permanently accessible to the AI model, any future model update, any jailbreak, and any prompt injection attack that can get the AI to call the tool.

Never return plaintext credential values to the AI.

Anti-pattern 3: Separate credentials for each MCP server

When you have 10 MCP servers, you have 10 sets of credentials to manage. Each one:

  • Needs to be rotated independently
  • Has no unified audit log
  • Gets configured in its own way
  • Creates 10 separate attack surfaces

This isn’t wrong so much as it’s unscalable. The correct architecture centralizes credential management.

The correct architecture

The right approach uses a single credential broker MCP server that all other secrets flow through:

Claude
├── vault_run("gh pr list") ─────────────→ OpaqueVault (credential broker)
│ │
│ ├── Decrypts GITHUB_TOKEN locally
│ ├── Spawns: gh pr list
│ │ └── env: GITHUB_TOKEN=xxx
│ └── Returns: stdout + exit code
├── vault_run("stripe customers list") ──→ OpaqueVault
│ │
│ ├── Decrypts STRIPE_SECRET_KEY
│ ├── Spawns: stripe customers list
│ └── Returns: stdout + exit code
└── vault_list_secrets("my-app/prod") ──→ OpaqueVault
└── Returns: [names only, no values]

Key properties of this architecture:

  1. One MCP server manages all credentials — single audit trail, single rotation surface, single configuration point
  2. Credentials are decrypted locally — the broker (OpaqueVault) runs on your machine, decrypts in memory, injects into subprocess env
  3. The remote server stores only ciphertext — compromising api.opaquevault.com yields nothing decryptable
  4. Command output is scanned before it reaches Claude — OpaqueVault sanitizes subprocess stdout/stderr and blocks known secret patterns before returning results

A note on point 4: vault_run removes the most direct vector (credentials in context), but it doesn’t prevent a subprocess from echoing secrets to its own stdout. If you run a command like env or printenv, OpaqueVault’s output interceptor will catch and block known secret values — but the interceptor works on known patterns. Avoid running introspection commands that dump environment variables.

How OpaqueVault implements this

OpaqueVault is purpose-built for this architecture. The ov mcp serve process is the local credential broker. It exposes seven MCP tools:

vault_run — the primary tool. Runs any command with secrets from a specified app/environment namespace injected as env vars. The command and its output never persist to disk.

vault_run(
command: ["node", "scripts/migrate.js"],
secret_names: ["DATABASE_URL"],
app: "my-saas", # optional, uses .ov.yaml default
environment: "staging" # optional, uses .ov.yaml default
)

vault_list_secrets — returns secret names and metadata. Never returns values.

vault_create_secret, vault_update_secret, vault_delete_secret — full lifecycle management. Claude can create and rotate secrets through these tools. Secret values passed to create/update are encrypted immediately client-side and never returned to Claude — they are write-only from Claude’s perspective once stored.

vault_status — returns vault health, session info, and whether the vault is unlocked.

Configuring MCP servers without credentials in the config

With OpaqueVault as your credential broker, other MCP servers don’t need credentials in their config files at all. Instead, you launch them through vault_run:

Instead of this (unsafe):

{
"mcpServers": {
"github": {
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxx" }
}
}
}

Do this (safe):

{
"mcpServers": {
"opaquevault": {
"command": "ov",
"args": ["mcp", "serve"]
}
}
}

Then tell Claude to use vault_run when it needs to call GitHub CLI commands. The GitHub token lives in the vault:

Terminal window
ov secret create my-project/development/GITHUB_TOKEN

When Claude runs vault_run with command ["gh", "pr", "list", "--repo", "owner/repo"] and secret GITHUB_TOKEN, OpaqueVault injects the token into the gh process environment. The token never appears in the MCP config, and Claude never sees the value.

Namespace design for multiple services

For projects using many external services, the app/environment/name hierarchy keeps credentials organized:

Terminal window
# Source control
ov secret create my-project/development/GITHUB_TOKEN
# Database
ov secret create my-project/development/DATABASE_URL
ov secret create my-project/production/DATABASE_URL
# Payment processing
ov secret create my-project/development/STRIPE_SECRET_KEY
ov secret create my-project/production/STRIPE_SECRET_KEY
# AI services (for your app, not Claude itself)
ov secret create my-project/development/OPENAI_API_KEY
# Email
ov secret create my-project/development/SENDGRID_API_KEY
# Monitoring
ov secret create my-project/production/SENTRY_DSN
ov secret create my-project/production/DATADOG_API_KEY

The .ov.yaml in each project sets the default context:

app: my-project
environment: development

When Claude calls vault_run without specifying app/environment, it uses the project defaults. Note: make sure .ov.yaml is set correctly before running commands — OpaqueVault will use the default namespace from whichever directory Claude is operating in. If you’re working across multiple projects, be explicit with app and environment parameters rather than relying on defaults.

Audit logging

Every vault_run call generates an audit log entry:

2026-04-13T10:23:41Z vault_run my-project/development ACCESSED [hmac:a3f9...]
2026-04-13T10:24:02Z vault_run my-project/development ACCESSED [hmac:a3f9...]
2026-04-13T10:31:17Z vault_run my-project/production ACCESSED [hmac:b7c2...]

Secret references in audit logs use HMAC tokens rather than plaintext names. This means a compromised audit log doesn’t become a map of what credentials you hold — an attacker needs the HMAC key (derived from your KEK) to reverse a token back to a name. The audit log answers “was this credential accessed, and when?” without advertising your full secrets inventory.

Rotation workflow

When a credential needs to be rotated:

Terminal window
# 1. Issue a new key in Stripe's dashboard (don't revoke the old one yet)
# 2. Update the value in the vault
ov secret update my-project/production/STRIPE_SECRET_KEY
# 3. Verify the new value works
ov run --secrets STRIPE_SECRET_KEY -- stripe customers list
# 4. If verification succeeds: revoke the old key in Stripe's dashboard
# 5. If verification fails: re-run step 2 with the original key to roll back

The ordering matters: issue the new key first, update the vault, verify, then revoke the old key. This gives you a rollback path at every step. Because the credential is stored in one place, every subsequent vault_run automatically uses the updated value — no MCP config files to update, no .env files to edit, no teammates to notify.

Reference: full MCP config without credentials

This is the complete MCP configuration for a project using GitHub, Stripe, and a database — with no credentials in the config file:

{
"mcpServers": {
"opaquevault": {
"command": "ov",
"args": ["mcp", "serve"]
}
}
}

That’s it. One MCP server. No credentials. All secrets live in the vault, get decrypted locally on demand, and are injected into subprocess environments without touching the AI’s context.


OpaqueVault is the MCP-native secret manager for AI coding agents. Get started free →

Related: MCP Secret Manager — How OpaqueVault Works · Zero-Knowledge Secret Manager

Zero-knowledge secrets for AI agents

Keep credentials out of Claude's context window.

OpaqueVault encrypts secrets client-side and injects them into subprocesses — your AI agent never sees the plaintext value.

Get started free → ← More posts