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:

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:

See also

Find out if your MCP server has credential rotation gaps before they become incidents.

Run a free audit → How grading works →