Topic: OAuth refresh token security

MCP server OAuth refresh token security

Refresh tokens are the long-lived half of the OAuth token pair. In MCP servers they are stored server-side, rotated on every access-token refresh, and must be revocable immediately. Rotation without replay detection, sliding-window expiry, missing client binding, and incomplete revocation propagation each create exploitable windows that survive long after an access token has expired.

Refresh token rotation without replay detection

OAuth 2.0 refresh token rotation (RFC 6749 §10.4, recommended in Security BCP RFC 9700) invalidates the old refresh token when a new one is issued. The attack scenario: an attacker steals a refresh token at time T. The legitimate user refreshes at T+1, receiving a new token; the old token is marked consumed. At T+2, the attacker replays the stolen token. Without replay detection the server issues yet another new refresh token to the attacker — the user and attacker now both hold valid tokens derived from the same family.

The fix is token family revocation on replay: store each token with a family_id and a version counter. When a token is presented, check its version against the stored version for that family. If the presented version is older than the current version, the token has already been consumed — revoke every token in the family immediately.

// Token table row: { id, family_id, version, consumed_at }
async function handleRefresh(tokenValue) {
  const token = await db.tokens.findByValue(tokenValue);
  if (!token) throw new Error('invalid_grant');

  const family = await db.tokenFamilies.find(token.family_id);

  if (token.version < family.current_version) {
    // Replay detected — revoke the entire family
    await db.tokenFamilies.revokeAll(token.family_id);
    throw new Error('invalid_grant: token_replay_detected');
  }

  // Consume current token and issue a new one
  await db.tokens.markConsumed(token.id);
  const newToken = await issueRefreshToken({
    family_id: token.family_id,
    version: family.current_version + 1,
    user_id: family.user_id,
  });
  await db.tokenFamilies.update(token.family_id, {
    current_version: family.current_version + 1,
  });
  return newToken;
}

Infinite refresh token chains via sliding-window expiry

Some implementations renew the refresh token's expiry on each use: expires_at = now() + 30d. A user who refreshes once a month never sees their refresh token expire. An attacker who compromises the token gains indefinite access — the token effectively never expires as long as the attacker keeps refreshing it, even while the legitimate user has stopped using the application.

Correct practice: set an absolute expiry at issuance and never extend it. The absolute expiry is stored in the token family record, not the individual token. Optionally add a shorter inactivity window, but only to restrict access — never to extend it beyond the absolute limit.

async function issueTokenFamily(userId, clientId) {
  const ABSOLUTE_LIFETIME_DAYS = 90;
  const INACTIVITY_WINDOW_DAYS = 14;

  const now = Date.now();
  const family = await db.tokenFamilies.create({
    user_id: userId,
    client_id: clientId,
    issued_at: now,
    // Hard ceiling — never updated
    absolute_expires_at: now + ABSOLUTE_LIFETIME_DAYS * 86400_000,
    // Inactivity window — updated on each refresh, but capped at absolute
    active_expires_at: now + INACTIVITY_WINDOW_DAYS * 86400_000,
    current_version: 1,
  });
  return family;
}

async function checkFamilyExpiry(family) {
  const now = Date.now();
  if (now > family.absolute_expires_at) throw new Error('refresh_token_expired');
  if (now > family.active_expires_at)   throw new Error('refresh_token_inactive');
}

Client fingerprint binding with HMAC

A refresh token must only be usable by the client that originally obtained it. Without binding, a token stolen from client A (e.g., a mobile app) can be used from client B (an attacker's server). Bind the token at issuance to client_id, redirect_uri, and an optional user_agent hash. Use HMAC-SHA256 to embed the binding inside the token value itself so it cannot be separated from the token.

import { createHmac, timingSafeEqual } from 'node:crypto';

function issueRefreshTokenValue(tokenId, clientId, secret) {
  const mac = createHmac('sha256', secret)
    .update(`${tokenId}:${clientId}`)
    .digest('base64url');
  // Token value = tokenId.mac — both parts needed to verify
  return `${tokenId}.${mac}`;
}

function verifyRefreshTokenBinding(tokenValue, clientId, secret) {
  const [tokenId, mac] = tokenValue.split('.');
  const expected = createHmac('sha256', secret)
    .update(`${tokenId}:${clientId}`)
    .digest('base64url');
  const expectedBuf = Buffer.from(expected, 'base64url');
  const actualBuf   = Buffer.from(mac,      'base64url');
  if (expectedBuf.length !== actualBuf.length) return false;
  return timingSafeEqual(expectedBuf, actualBuf);
}

On every /token refresh request, call verifyRefreshTokenBinding(token, req.body.client_id, SECRET) before looking the token up in the database. A binding mismatch returns invalid_client, not invalid_grant — revealing as little as possible about why the request was rejected.

Revocation propagation latency via Redis revocation set

Access tokens are short-lived (typically 15 minutes) and can be left to expire naturally. Refresh tokens must be revocable immediately — on user logout, password change, or suspicious activity alert. A database query on every refresh is acceptable, but a Redis-based revocation SET adds a fast O(1) check for the common case and survives database slow queries during incidents.

const redis = new Redis(process.env.REDIS_URL);

async function revokeTokenFamily(familyId, absoluteExpiresAt) {
  const ttlSeconds = Math.ceil((absoluteExpiresAt - Date.now()) / 1000);
  if (ttlSeconds > 0) {
    // SET key expires when the token would have anyway — no unbounded growth
    await redis.set(`revoked:family:${familyId}`, '1', 'EX', ttlSeconds);
  }
  await db.tokenFamilies.markRevoked(familyId);
}

async function isRevoked(familyId) {
  // Fast path: Redis check before hitting the DB
  const hit = await redis.get(`revoked:family:${familyId}`);
  if (hit) return true;
  // Slow path: DB authoritative source (handles Redis cache miss)
  const family = await db.tokenFamilies.find(familyId);
  return family?.revoked_at != null;
}

Call isRevoked(token.family_id) as the first step in the refresh flow, before any other token validation. On user password change, revoke all token families for that user ID in a single loop.

Token storage security: where refresh tokens live on the server

Refresh tokens are long-lived credentials that must be stored securely on the server side. The choice of storage backend affects both security and scalability. Three common patterns and their tradeoffs:

Database-only storage (PostgreSQL/MySQL): Store the hashed refresh token (bcrypt or Argon2) in a refresh_tokens table indexed by family_id. Lookup is a single indexed SELECT. Revocation is a single UPDATE. No external dependency. The downside is that a database slow query during a security incident (e.g., mass revocation of tokens for 10M users after a breach) can delay the revocation. Suitable for most MCP deployments up to ~10M tokens.

Redis-primary with database backup: Store the token in Redis with TTL equal to absolute_expires_at. The database is the source of truth for family metadata (family_id, user_id, client_id, issued_at, absolute_expires_at). On each refresh, check Redis first (O(1) lookup), fall through to the database on miss. Revocation writes to both. This pattern supports high-throughput token refresh (millions of requests per second) at the cost of Redis infrastructure and cache consistency complexity.

Opaque token with signed envelope: Issue a refresh token that is itself a signed JWT containing {family_id, version, client_id, issued_at, absolute_expires_at}. The signature prevents tampering; the family_id and version are looked up in the database to check for replay and revocation. This reduces the database row to a small revocation flag and version counter, but it requires the full JWT to be stored nowhere — the token value IS the data. The risk: if the signing key is compromised, all tokens are forgeable. Key rotation must be handled carefully (grace period for old-key tokens during rotation).

// Hashing refresh tokens at rest — never store plaintext
import { hash, verify } from 'argon2';

async function storeRefreshToken(tokenValue, familyId, metadata) {
  // Hash the token value before storing — attacker who reads the DB
  // cannot directly use the hashed value as a token
  const tokenHash = await hash(tokenValue, {
    type: argon2.argon2id,
    memoryCost: 2 ** 16,
    timeCost: 3,
  });
  await db.refreshTokens.create({
    family_id: familyId,
    token_hash: tokenHash,
    version: metadata.version,
    ...metadata,
  });
}

async function lookupRefreshToken(tokenValue) {
  // Cannot look up by hash directly — must iterate family or store a lookup key
  // Store a separate short lookup key: first 16 bytes of the token, unencrypted
  const lookupKey = tokenValue.slice(0, 16);
  const candidates = await db.refreshTokens.findByLookupKey(lookupKey);
  for (const candidate of candidates) {
    if (await verify(candidate.token_hash, tokenValue)) return candidate;
  }
  return null;
}

The lookup key pattern (storing the first N bytes of the token unencrypted as a lookup index while hashing the rest) avoids a full-table scan while still preventing an attacker who reads the database from constructing a valid token. An attacker who reads only the lookup key cannot reconstruct the full token value needed to authenticate.

Scope restriction on refresh tokens for MCP tool access

OAuth scopes define what a token is permitted to do. Refresh tokens issued by an MCP server should carry the same scope restrictions as the access tokens they can generate. A refresh token with scope tools:read should only be able to refresh access tokens with tools:read — not tools:write, admin, or any scope the user did not originally authorize.

// Scope validation on refresh — prevent scope elevation
async function handleRefresh(tokenValue, requestedScopes) {
  const token = await lookupRefreshToken(tokenValue);
  if (!token) throw new Error('invalid_grant');

  // Requested scopes must be a subset of the token's granted scopes
  const grantedScopes = new Set(token.scopes);
  for (const scope of requestedScopes) {
    if (!grantedScopes.has(scope)) {
      throw new Error(`invalid_scope: ${scope} not in granted scopes`);
    }
  }

  // Issue access token with only the requested (sub)scopes
  const accessToken = await issueAccessToken({
    user_id: token.user_id,
    client_id: token.client_id,
    scopes: requestedScopes,
    expires_in: 900, // 15 minutes
  });
  return accessToken;
}

MCP deployments often grant broad tool access at login ("all tools the user is permitted to use") and then issue task-specific sub-tokens for individual tool calls. The refresh token should carry the broad grant; the access token should be scoped to the minimum needed for the current tool. Clients that request narrower scopes at refresh time receive a narrower access token, reducing the blast radius if that short-lived token is compromised.

SkillAudit findings for OAuth refresh token security

CRITICAL −23 No refresh token replay detection — stale token accepted after rotation; attacker who stole a token retains access indefinitely.
CRITICAL −20 Sliding-window expiry on refresh tokens — expiry reset on each use allows an infinite refresh chain; token effectively never expires.
HIGH −16 Refresh token not bound to client_id — token issued to a mobile client is usable from any other client or server environment.
HIGH −12 Revocation only invalidates the current access token — compromised refresh token survives until absolute expiry with no way to revoke mid-flight.

Run a SkillAudit scan to detect OAuth refresh token vulnerabilities in your MCP server. See also MCP server authentication security.