Topic: mcp server oauth security

MCP server OAuth security — token scope minimization, PKCE, refresh token rotation, and safe token storage

MCP servers increasingly act as OAuth clients, obtaining tokens on behalf of users to call third-party APIs like GitHub, Google, or Slack. Each step of the OAuth flow introduces a distinct security risk: scopes can be over-requested, authorization codes can be intercepted without PKCE, refresh tokens can be reused after theft, and access tokens can be stored where they get logged or leaked. The four patterns below are the most impactful OAuth hardening steps for MCP server authors.

Pattern 1: Overly broad token scopes — requesting more permissions than needed

OAuth scopes define the blast radius of a stolen token. An MCP server that requests repo (full repository read/write) on GitHub when it only needs to read public repository metadata gives any attacker who intercepts or extracts the token the ability to delete branches, overwrite commits, or exfiltrate private repositories. The principle of least privilege applies directly: request the narrowest scope that makes your tools functional.

Map each MCP tool to the specific API endpoints it calls, find the minimum scope that grants access to those endpoints, and request exactly that set. For GitHub, public_repo instead of repo already eliminates write access and private repository access. For Google, https://www.googleapis.com/auth/drive.readonly instead of https://www.googleapis.com/auth/drive prevents writes.

WRONG — requesting broad write scopes for read-only tools

// Tool only reads public repo metadata — lists branches, reads README
// WRONG: 'repo' grants full private repo read/write + delete
function buildAuthUrl(clientId, redirectUri, state) {
  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: 'repo user',          // WRONG: write + private access not needed
    state,
    response_type: 'code',
  });
  return `https://github.com/login/oauth/authorize?${params}`;
}

RIGHT — request the minimum scope each tool actually requires

// RIGHT: enumerate exactly what each tool calls, then pick the narrowest scopes
//
// Tool: list_public_repos  -> needs: public_repo (read only, public repos)
// Tool: get_user_profile   -> needs: read:user
// No tool writes anything  -> no write scope requested
//
// Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps

function buildAuthUrl(clientId, redirectUri, state) {
  const REQUIRED_SCOPES = [
    'public_repo',   // read public repos — not 'repo' (which adds private + write)
    'read:user',     // read user profile — not 'user' (which adds email write)
  ].join(' ');

  const params = new URLSearchParams({
    client_id: clientId,
    redirect_uri: redirectUri,
    scope: REQUIRED_SCOPES,
    state,
    response_type: 'code',
  });
  return `https://github.com/login/oauth/authorize?${params}`;
}

Pattern 2: Missing PKCE — authorization code interception

The Authorization Code flow exchanges a short-lived authorization code for an access token. Without PKCE (Proof Key for Code Exchange), any process that can read the redirect URI — a malicious app registered for the same custom scheme, a browser extension, or a network intermediary — can steal the code and exchange it for tokens. PKCE binds the code to the specific client instance that initiated the flow by requiring the client to prove it generated the original random verifier.

The S256 method hashes a 32-byte random code_verifier with SHA-256, base64url-encodes the result as the code_challenge, and sends the challenge to the authorization server. On the token exchange, the server re-hashes the verifier provided by the client and checks it matches. An attacker who intercepts the code cannot exchange it without the verifier, which never leaves the initiating client.

WRONG — Authorization Code flow without PKCE

import crypto from 'node:crypto';

// WRONG: no code_verifier / code_challenge — code is exchangeable by anyone
function startOAuthFlow(clientId, redirectUri) {
  const state = crypto.randomBytes(16).toString('hex');
  const authUrl = new URL('https://auth.example.com/authorize');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', clientId);
  authUrl.searchParams.set('redirect_uri', redirectUri);
  authUrl.searchParams.set('state', state);
  // Missing: code_challenge and code_challenge_method
  return { authUrl: authUrl.toString(), state };
}

async function exchangeCode(code, clientId, clientSecret, redirectUri) {
  // WRONG: no code_verifier sent — server cannot verify the exchanger
  const res = await fetch('https://auth.example.com/token', {
    method: 'POST',
    body: new URLSearchParams({ grant_type: 'authorization_code', code,
      client_id: clientId, client_secret: clientSecret,
      redirect_uri: redirectUri }),
  });
  return res.json();
}

RIGHT — PKCE S256 with verifier stored in session, never transmitted until exchange

import crypto from 'node:crypto';

function generatePKCE() {
  // RIGHT: 32 random bytes = 256 bits of entropy, base64url-encoded
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url'); // S256 method
  return { verifier, challenge };
}

function startOAuthFlow(clientId, redirectUri) {
  const state    = crypto.randomBytes(16).toString('hex');
  const { verifier, challenge } = generatePKCE();

  const authUrl = new URL('https://auth.example.com/authorize');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', clientId);
  authUrl.searchParams.set('redirect_uri', redirectUri);
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', challenge);         // RIGHT
  authUrl.searchParams.set('code_challenge_method', 'S256');    // RIGHT

  // Store verifier in server-side session keyed by state — never send it yet
  sessionStore.set(state, { verifier, expiresAt: Date.now() + 600_000 });

  return { authUrl: authUrl.toString(), state };
}

async function exchangeCode(code, state, clientId, redirectUri) {
  const session = sessionStore.get(state);
  if (!session || Date.now() > session.expiresAt) throw new Error('Invalid state');
  sessionStore.delete(state); // one-time use

  // RIGHT: send verifier here — server hashes it and checks against stored challenge
  const res = await fetch('https://auth.example.com/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      client_id: clientId,
      redirect_uri: redirectUri,
      code_verifier: session.verifier,  // RIGHT
    }),
  });
  return res.json();
}

Pattern 3: No refresh token rotation — reuse of long-lived credentials

A refresh token with an indefinite lifetime that is never rotated becomes a permanent credential: steal it once and maintain access forever, even after the user revokes the session through the application UI (which typically only invalidates the current access token). OAuth 2.0 Security Best Current Practice (RFC 9700) mandates refresh token rotation: every time a refresh token is used to obtain a new access token, the authorization server must issue a new refresh token and invalidate the old one. More importantly, if the old token is presented again — indicating a possible theft — the entire token family should be revoked.

In a custom MCP OAuth server (or when wrapping a provider that does not natively rotate), implement rotation at the token-issuance layer and store a familyId plus a generation counter per token.

WRONG — issuing the same refresh token repeatedly without rotation

// WRONG: refresh token never changes — theft is undetectable
async function refreshAccessToken(refreshToken) {
  const stored = await db.tokens.findOne({ refreshToken });
  if (!stored) throw new Error('Invalid refresh token');

  // Issue a new access token but keep the same refresh token
  const accessToken = issueAccessToken(stored.userId, stored.scopes);
  return { access_token: accessToken, refresh_token: refreshToken }; // WRONG
}

RIGHT — rotate refresh token on every use and revoke family on reuse detection

import crypto from 'node:crypto';

async function refreshAccessToken(incomingRefreshToken) {
  const stored = await db.tokens.findOne({ refreshToken: incomingRefreshToken });

  if (!stored) {
    // RIGHT: token not found — could be a replayed stolen token
    // Attempt to find by familyId to revoke the entire family
    const byFamily = await db.tokens.findOne({ refreshToken: incomingRefreshToken });
    // If we stored revoked tokens with a "revoked" flag, check here:
    const revoked = await db.revokedTokens.findOne({ token: incomingRefreshToken });
    if (revoked) {
      // RIGHT: reuse of a rotated token — revoke the whole family
      await db.tokens.deleteMany({ familyId: revoked.familyId });
      throw new Error('Refresh token reuse detected — all sessions revoked');
    }
    throw new Error('Invalid refresh token');
  }

  // RIGHT: rotate — generate a new refresh token
  const newRefreshToken = crypto.randomBytes(32).toString('hex');

  // Mark old token as revoked (retain for reuse detection, with TTL)
  await db.revokedTokens.insertOne({
    token: incomingRefreshToken,
    familyId: stored.familyId,
    revokedAt: new Date(),
  });

  // Persist new token in same family, incremented generation
  await db.tokens.replaceOne(
    { refreshToken: incomingRefreshToken },
    {
      refreshToken: newRefreshToken,
      familyId: stored.familyId,
      generation: stored.generation + 1,
      userId: stored.userId,
      scopes: stored.scopes,
      issuedAt: new Date(),
    }
  );

  const accessToken = issueAccessToken(stored.userId, stored.scopes);
  return { access_token: accessToken, refresh_token: newRefreshToken };
}

Pattern 4: Unsafe token storage — tokens in plaintext files or logged environment variables

Access tokens stored in plaintext files, written to .env files committed to source control, or interpolated into log messages are among the most common sources of credential leakage in MCP server deployments. Even if an .env file is gitignored, tokens passed through process.env can be captured by crash reporters, debug loggers, or console.log(process.env) calls left in during development. A token in a log line is effectively public the moment that log ships to any aggregation service.

Tokens at rest should be encrypted with a key that is stored separately from the token data. The encryption key itself can come from a secrets manager, a hardware security module, or at minimum an environment variable that is explicitly excluded from logging. The example below uses AES-256-GCM, which provides both confidentiality and authentication — detecting tampering with the ciphertext.

WRONG — storing access tokens in plaintext on disk or in logs

import fs from 'node:fs/promises';

// WRONG: token written to plaintext file — readable by any process on the host
async function saveToken(userId, accessToken, refreshToken) {
  const tokenFile = `/var/lib/mcp-server/tokens/${userId}.json`;
  await fs.writeFile(tokenFile, JSON.stringify({ accessToken, refreshToken }));
  // WRONG: token value appears in log output
  console.log(`Saved token for ${userId}: ${accessToken}`);
}

// WRONG: token loaded from a file path where process.env dump would expose it
async function loadToken(userId) {
  const tokenFile = process.env.TOKEN_FILE || `/tmp/token_${userId}`;
  return JSON.parse(await fs.readFile(tokenFile, 'utf8'));
}

RIGHT — encrypt tokens at rest with AES-256-GCM; never log token values

import crypto from 'node:crypto';
import fs from 'node:fs/promises';

// Encryption key: 32 bytes from a secrets manager or dedicated env var
// CRITICAL: never log or include ENCRYPTION_KEY in general process.env dumps
const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY, 'hex'); // 64 hex chars = 32 bytes

function encryptToken(plaintext) {
  const iv = crypto.randomBytes(12); // 96-bit IV for GCM
  const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag(); // 16-byte authentication tag
  // Store iv + authTag + ciphertext as a single base64 blob
  return Buffer.concat([iv, authTag, encrypted]).toString('base64');
}

function decryptToken(blob) {
  const buf = Buffer.from(blob, 'base64');
  const iv       = buf.subarray(0, 12);
  const authTag  = buf.subarray(12, 28);
  const encrypted = buf.subarray(28);
  const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
  decipher.setAuthTag(authTag);
  return decipher.update(encrypted) + decipher.final('utf8');
}

// RIGHT: store encrypted blob; never log token values
async function saveToken(userId, accessToken, refreshToken) {
  const payload = JSON.stringify({ accessToken, refreshToken });
  const encrypted = encryptToken(payload);
  const tokenFile = `/var/lib/mcp-server/tokens/${userId}.enc`;
  await fs.writeFile(tokenFile, encrypted, { mode: 0o600 }); // owner-read only
  console.log(`Token saved for user ${userId}`); // RIGHT: log user, never token value
}

async function loadToken(userId) {
  const tokenFile = `/var/lib/mcp-server/tokens/${userId}.enc`;
  const encrypted = await fs.readFile(tokenFile, 'utf8');
  return JSON.parse(decryptToken(encrypted));
}

How SkillAudit detects OAuth security issues

SkillAudit's static analysis traces OAuth flow implementations through MCP server source code. It identifies authorization URL construction calls that set scope to broad values (e.g. any scope containing write, admin, or *) without a corresponding least-privilege annotation, and flags URLSearchParams or query-string builders in authorization flows that do not include code_challenge and code_challenge_method=S256 parameters. Token storage calls — fs.writeFile, localStorage.setItem, direct database writes — that operate on a variable whose name or type indicates a token value and that lack encryption wrapping are flagged as high-severity plaintext storage findings.

Missing refresh token rotation is detected by examining token-refresh handler functions for the absence of a new token generation and old-token revocation path. These findings map to the Authentication and Secrets Handling axes of the SkillAudit grade, both of which are weighted heavily because token compromise typically provides full account access. Run a free scan at skillaudit.dev to see the OAuth posture of your MCP server and get a prioritized fix list.