MCP `env` blocks quietly collect GitHub PATs, database URLs, Stripe keys and Anthropic tokens. Each one is a stale supply-chain incident waiting to happen. Rotation is the cheapest control you own — here is a working strategy, a schedule, and the code.
Why MCP makes rotation urgent
Three reasons rotation matters more for MCP than for classical backends:
- Config is on developer laptops, which are lost, imaged, and shared more than prod servers.
- MCP servers run with the full credential — no token exchange, no least-privilege scoping by default.
- Credential exposure is often invisible — a compromised MCP package keeps working, so the breach goes undetected for months.
Four credential categories by risk
| Category | Example | Rotation cadence |
|---|---|---|
| Critical | Production DB, payment API | Every 30 days |
| High | GitHub org admin PAT, cloud admin | Every 60 days |
| Medium | SaaS read-only tokens | Every 90 days |
| Low | Public API keys with rate limits | Every 180 days or on event |
Three patterns, in order of preference
1. Secret manager reference
Best pattern: the MCP config references a secret manager path, not the literal value. Rotation happens in the manager, zero config changes on developer machines.
{
"env": {
"GITHUB_TOKEN": "op://Dev/github-mcp/token"
}
}
A tiny wrapper resolves the reference before launch. Compatible with 1Password CLI, Doppler, AWS Secrets Manager, and Vault.
2. Workload identity
Second best: the MCP server receives a short-lived token minted on demand, no long-term secret at all. Supported natively by cloud MCP servers (AWS MCP, GCP MCP) and by any server that can call an OIDC exchange.
3. Plain env, rotated on schedule
Fallback for servers that do not support the above. A cron job replaces the value in the config file and restarts the host.
Automating rotation in 80 lines
// rotate.ts
import { readFile, writeFile } from 'node:fs/promises';
import { execSync } from 'node:child_process';
const configPath = process.env.MCP_CONFIG_PATH!;
const rotations = [
{
name: 'github',
envKey: 'GITHUB_TOKEN',
mint: () => execSync('gh auth token --refresh').toString().trim(),
},
{
name: 'postgres',
envKey: 'DATABASE_URL',
mint: () => mintPgCredentials(),
},
];
const raw = await readFile(configPath, 'utf-8');
const cfg = JSON.parse(raw);
for (const r of rotations) {
for (const server of Object.values(cfg.mcpServers) as Array<Record<string, unknown>>) {
const env = server.env as Record<string, string> | undefined;
if (env?.[r.envKey]) {
env[r.envKey] = r.mint();
}
}
}
await writeFile(configPath, JSON.stringify(cfg, null, 2));
console.log('Rotated', rotations.length, 'credentials.');
Rolling credentials without downtime
Naive rotation races: old token revoked before new token reaches every host. The three-step dance:
- Create the new token, keep the old one live.
- Deploy the new token to every host and verify first use.
- Revoke the old token only after the deploy settles.
When you suspect a leak
- Revoke the suspect token at the source (GitHub, Stripe, your DB) immediately.
- Check audit logs on the source for activity outside your IPs or hours.
- Rotate anything that shared a machine or config bundle with the compromised token.
- Run the malicious MCP checklist on every installed server.
- File a public advisory if the compromise affected customers.
What good looks like
The target state has no human ever pasting a raw token into a config file. Every value is a reference. Every reference resolves at launch from a short-lived secret. The secret manager records access per use. If that sounds like prod-infrastructure hygiene, it is — MCP configs deserve the same treatment.