Topic: OAuth token rotation in MCP servers
OAuth Token Rotation in MCP Servers
MCP servers that call external APIs on behalf of users commonly hold OAuth access tokens. When those tokens are long-lived — or when their refresh tokens are stored in plaintext environment variables — a single credential leak grants persistent API access that outlasts any incident response window. Token rotation is the practice of exchanging short-lived access tokens for fresh ones automatically, so the credential window of exposure is measured in minutes rather than months.
Token TTL vs rotation interval
OAuth access tokens have a TTL set by the authorization server — commonly 3600 seconds (1 hour) for Google APIs, 7200 seconds for GitHub Apps, and 900 seconds for AWS STS tokens. The rotation interval is how often your server proactively exchanges the token before it expires. A safe rule is to rotate at 80% of TTL: for a 3600-second token, rotate at 2880 seconds (48 minutes). This provides a 12-minute buffer for the exchange to complete and the new token to propagate before the old one becomes invalid.
GitHub Apps use a different model: installation access tokens have a fixed 1-hour TTL and cannot be manually rotated — the token simply expires and you request a new one using the App's private key JWT. This is distinct from OAuth refresh token rotation, but the pattern is the same: cache the token, track its expiry, and re-request before the expiry window closes. Never hardcode a GitHub App installation token — they will stop working in an hour and your server will silently fail.
Implementing a refresh token middleware in TypeScript
The standard pattern is an interceptor that catches 401 responses, performs the token exchange, and retries the original request with the new token. A mutex prevents concurrent refreshes when multiple in-flight requests all receive 401 simultaneously.
import { Mutex } from 'async-mutex';
interface TokenStore {
accessToken: string;
refreshToken: string;
expiresAt: number; // Unix timestamp in ms
}
class OAuthTokenManager {
private store: TokenStore;
private mutex = new Mutex();
private readonly rotateAtFraction = 0.8; // rotate at 80% of TTL
constructor(private tokenEndpoint: string, private clientId: string, private clientSecret: string) {
// Load initial tokens from a secrets vault, not process.env
this.store = this.loadFromVault();
}
async getAccessToken(): Promise {
const now = Date.now();
const ttlMs = this.store.expiresAt - now;
const originalTtlMs = this.store.expiresAt - this.store.issuedAt;
const shouldRotate = ttlMs < originalTtlMs * (1 - this.rotateAtFraction);
if (shouldRotate) {
// Mutex prevents concurrent refresh storms
return this.mutex.runExclusive(() => this.refresh());
}
return this.store.accessToken;
}
private async refresh(): Promise {
// Re-check inside the mutex — another caller may have already refreshed
if (Date.now() < this.store.expiresAt * 0.8) {
return this.store.accessToken;
}
const resp = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.store.refreshToken,
client_id: this.clientId,
client_secret: this.clientSecret,
}),
});
if (!resp.ok) {
// Refresh token is revoked — surface this clearly rather than silently failing
const body = await resp.text();
throw new Error(`Token refresh failed (${resp.status}): ${body}`);
}
const data = await resp.json();
const now = Date.now();
this.store = {
accessToken: data.access_token,
// Some providers rotate the refresh token on each use (RFC 6749 §10.4)
refreshToken: data.refresh_token ?? this.store.refreshToken,
expiresAt: now + data.expires_in * 1000,
issuedAt: now,
};
// Persist updated tokens to vault immediately
await this.saveToVault(this.store);
return this.store.accessToken;
}
// Intercept API calls — retry once on 401 with a fresh token
async callApi(url: string, init: RequestInit = {}): Promise {
const token = await this.getAccessToken();
const resp = await fetch(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${token}` },
});
if (resp.status === 401) {
// Force a refresh even if we thought the token was valid
const freshToken = await this.mutex.runExclusive(() => this.refresh());
return fetch(url, {
...init,
headers: { ...init.headers, Authorization: `Bearer ${freshToken}` },
});
}
return resp;
}
}
Note the double-checked lock inside refresh(): the first caller to acquire the mutex performs the exchange; subsequent callers that were waiting find the token already refreshed and return immediately without a second network request.
Storing refresh tokens securely
Refresh tokens are long-lived credentials — many providers issue refresh tokens that never expire until explicitly revoked. Storing a refresh token in a hardcoded environment variable in a .env file that ships with the server repository is the single most common credential exposure pattern SkillAudit finds in MCP server audits. The correct storage hierarchy, from most to least preferred:
- A secrets vault (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager) — fetched at runtime, rotated server-side, auditable
- An encrypted secrets file with the key stored separately (e.g., SOPS, age) — acceptable for self-hosted deployments
- Environment variable set at deploy time by the CI/CD pipeline — not stored in the repo; acceptable if the pipeline pulls from a vault
- Hardcoded
.envfile in the repository — never acceptable; this is the pattern SkillAudit flags as a HIGH credential exposure finding
When a provider supports refresh token rotation (Google, GitHub, and Okta all do under different names), the refresh token itself changes on each use. Your persistence layer must write the new refresh token atomically — if the write fails after a successful exchange, the old refresh token is now invalid and you are locked out.
Detecting revoked tokens before they fail requests
Token revocation can happen out-of-band: a user revokes access in the provider's dashboard, a security team rotates credentials in an incident, or an admin expires all tokens for a user account. Proactive rotation catches expiry-by-TTL, but not revocation. Two strategies complement rotation:
Token introspection (RFC 7662): Call the authorization server's /introspect endpoint with the current token. The server returns {"active": false} for revoked tokens. This costs one network round-trip per check, so suitable for startup health checks rather than per-request validation.
Webhook or event subscription: Some providers (GitHub, Slack) push revocation events to a configured webhook endpoint. Your server receives the event and immediately clears the cached token, forcing a re-authorization flow for the next request.
What SkillAudit checks
The credential exposure axis checks for OAuth token rotation antipatterns:
- Hardcoded refresh token in .env or source — HIGH; long-lived credential with no rotation mechanism
- Access token with TTL > 3600s stored without rotation logic — WARN; extended exposure window if the token is leaked
- No 401 retry / refresh logic on API calls — WARN; reactive rotation only; token expiry causes user-visible failures
- Refresh token stored in the same location as access token — INFO; losing one credential loses both; separate storage is safer
See also
- MCP server credential exposure — how credentials leak through logs, errors, and env var dumps
- Anatomy of a credential leak — end-to-end walkthrough of how a hardcoded token becomes a breach
- MCP server permission scope patterns — minimize the blast radius when a token is compromised
Find out if your MCP server has credential rotation gaps before they become incidents.
Run a free audit → How grading works →