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:
- One MCP server manages all credentials — single audit trail, single rotation surface, single configuration point
- Credentials are decrypted locally — the broker (OpaqueVault) runs on your machine, decrypts in memory, injects into subprocess env
- The remote server stores only ciphertext — compromising
api.opaquevault.comyields nothing decryptable - 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:
ov secret create my-project/development/GITHUB_TOKENWhen 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:
# Source controlov secret create my-project/development/GITHUB_TOKEN
# Databaseov secret create my-project/development/DATABASE_URLov secret create my-project/production/DATABASE_URL
# Payment processingov secret create my-project/development/STRIPE_SECRET_KEYov 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
# Emailov secret create my-project/development/SENDGRID_API_KEY
# Monitoringov secret create my-project/production/SENTRY_DSNov secret create my-project/production/DATADOG_API_KEYThe .ov.yaml in each project sets the default context:
app: my-projectenvironment: developmentWhen 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:
# 1. Issue a new key in Stripe's dashboard (don't revoke the old one yet)
# 2. Update the value in the vaultov secret update my-project/production/STRIPE_SECRET_KEY
# 3. Verify the new value worksov 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 backThe 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