Guide · Authentication

MCP server OAuth 2.0

OAuth 2.0 for MCP servers has one unusual constraint: LLM clients are not browsers. An LLM client (Claude Desktop, a Cursor plugin, an automated agent) cannot complete an OAuth 2.0 authorization code flow that requires a browser redirect to a callback URL — there is no browser to redirect, and no HTTP server listening for the callback. The OAuth 2.0 Device Authorization Grant (RFC 8628) was designed exactly for this use case: devices that cannot open a browser but need to authenticate a user. The LLM client initiates the flow, displays a URL and a code, polls the token endpoint until the user completes authorization on their phone or laptop, and then receives an access token. This guide covers the full device flow for MCP, plus the mcp-remote proxy pattern that enables OAuth for MCP clients that only support API keys.

TL;DR

Use the OAuth 2.0 Device Authorization Grant for LLM clients: POST /oauth2/device_authorization, display verification_uri_complete to the user, poll /oauth2/token with grant_type=urn:ietf:params:oauth:grant-type:device_code until authorization_pending clears, then use the access token as a Bearer token in the MCP Authorization header. Refresh before expiry using the refresh token. Discover endpoints via /.well-known/oauth-authorization-server to avoid hard-coding URLs.

Why device flow, not authorization code flow

The standard OAuth 2.0 authorization code flow assumes a browser that can navigate to the authorization URL and a redirect URI where the authorization code is delivered. LLM clients have neither:

FlowRequiresWorks for LLM clients?
Authorization CodeBrowser redirect, listening HTTP server for callbackNo — no browser, no HTTP listener
Authorization Code + PKCEBrowser redirect, listening HTTP server for callbackSometimes — if the client app has a local HTTP server (e.g. Claude Desktop)
Device Authorization GrantAbility to display a URL/code and poll an endpointYes — any client that can make HTTP requests and display text
Client CredentialsClient ID + secret (machine-to-machine)Yes — but no user context; tokens represent the application, not a user

Client Credentials is the right choice for server-to-server MCP integrations (automated agents, backend services). Device flow is the right choice when you need user-delegated access — the token represents a specific user who consented to the access, enabling per-user RBAC, audit trails, and token revocation per user.

Authorization server metadata discovery

Before starting any OAuth flow, discover the authorization server's endpoint URLs via its metadata document. This avoids hard-coding endpoint URLs that change across deployments and environments:

interface OAuthMetadata {
  device_authorization_endpoint: string;
  token_endpoint: string;
  jwks_uri: string;
  issuer: string;
  scopes_supported: string[];
  grant_types_supported: string[];
}

async function discoverOAuthMetadata(issuer: string): Promise<OAuthMetadata> {
  const metadataUrl = `${issuer}/.well-known/oauth-authorization-server`;
  const response = await fetch(metadataUrl);
  if (!response.ok) {
    throw new Error(`Failed to fetch OAuth metadata from ${metadataUrl}: ${response.status}`);
  }
  const metadata = await response.json() as OAuthMetadata;

  if (!metadata.device_authorization_endpoint) {
    throw new Error('Authorization server does not support Device Authorization Grant');
  }

  return metadata;
}

Cache the metadata document for at least 1 hour — it changes rarely and fetching it on every session start adds unnecessary latency. Verify that grant_types_supported includes urn:ietf:params:oauth:grant-type:device_code before attempting device flow.

Full device authorization flow

interface DeviceAuthResponse {
  device_code: string;
  user_code: string;
  verification_uri: string;
  verification_uri_complete: string; // pre-filled URI — use this when available
  expires_in: number;
  interval: number; // polling interval in seconds (default 5)
}

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  refresh_token?: string;
  scope?: string;
}

async function deviceAuthFlow(
  metadata: OAuthMetadata,
  clientId: string,
  scope: string
): Promise<TokenResponse> {
  // Step 1: Request device and user codes
  const deviceAuthRes = await fetch(metadata.device_authorization_endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: clientId,
      scope,
    }),
  });

  const deviceAuth = await deviceAuthRes.json() as DeviceAuthResponse;

  // Step 2: Display the user code and verification URL
  // Use verification_uri_complete when available (pre-fills the code)
  console.log(`\nTo authorize this MCP connection, visit:`);
  console.log(`  ${deviceAuth.verification_uri_complete ?? deviceAuth.verification_uri}`);
  if (!deviceAuth.verification_uri_complete) {
    console.log(`  Enter code: ${deviceAuth.user_code}`);
  }
  console.log(`\nWaiting for authorization...`);

  // Step 3: Poll token endpoint
  const expiresAt  = Date.now() + deviceAuth.expires_in * 1000;
  const pollIntervalMs = (deviceAuth.interval ?? 5) * 1000;

  while (Date.now() < expiresAt) {
    await new Promise(resolve => setTimeout(resolve, pollIntervalMs));

    const tokenRes = await fetch(metadata.token_endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
        device_code: deviceAuth.device_code,
        client_id: clientId,
      }),
    });

    const tokenData = await tokenRes.json() as TokenResponse & { error?: string };

    if (tokenRes.ok) {
      return tokenData; // Authorization complete
    }

    if (tokenData.error === 'authorization_pending') {
      continue; // User hasn't approved yet — keep polling
    }

    if (tokenData.error === 'slow_down') {
      // Server wants us to poll less frequently
      await new Promise(resolve => setTimeout(resolve, 5000));
      continue;
    }

    if (tokenData.error === 'access_denied') {
      throw new Error('User denied authorization');
    }

    throw new Error(`OAuth error: ${tokenData.error}`);
  }

  throw new Error('Device authorization expired — user did not complete authorization in time');
}

The slow_down error is a server-side signal to reduce polling frequency — the spec requires adding 5 seconds to the interval when you receive it. Failing to handle slow_down correctly can result in your client being rate-limited or blocked by the authorization server.

Token refresh before expiry

MCP sessions can last hours. OAuth access tokens typically expire in 1 hour or less. Proactively refresh the access token before it expires to avoid mid-session 401 failures:

class TokenManager {
  private accessToken: string;
  private refreshToken: string | undefined;
  private expiresAt: number;
  private tokenEndpoint: string;
  private clientId: string;

  constructor(tokenResponse: TokenResponse, tokenEndpoint: string, clientId: string) {
    this.accessToken   = tokenResponse.access_token;
    this.refreshToken  = tokenResponse.refresh_token;
    this.expiresAt     = Date.now() + (tokenResponse.expires_in - 60) * 1000; // refresh 60s early
    this.tokenEndpoint = tokenEndpoint;
    this.clientId      = clientId;
  }

  async getAccessToken(): Promise<string> {
    if (Date.now() < this.expiresAt) {
      return this.accessToken; // still valid
    }

    if (!this.refreshToken) {
      throw new Error('Access token expired and no refresh token available — re-authenticate');
    }

    const res = await fetch(this.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: this.refreshToken,
        client_id: this.clientId,
      }),
    });

    const newTokens = await res.json() as TokenResponse & { error?: string };

    if (!res.ok) {
      // Refresh token is expired or revoked — must re-authenticate
      throw new Error(`Token refresh failed: ${newTokens.error}`);
    }

    this.accessToken  = newTokens.access_token;
    this.expiresAt    = Date.now() + (newTokens.expires_in - 60) * 1000;
    // Some servers issue a new refresh token on each refresh (rotation)
    if (newTokens.refresh_token) {
      this.refreshToken = newTokens.refresh_token;
    }

    return this.accessToken;
  }
}

// Use in MCP session setup
const token = await tokenManager.getAccessToken();
const transport = new StreamableHTTPClientTransport(
  new URL(MCP_SERVER_URL),
  { headers: { Authorization: `Bearer ${token}` } }
);

The mcp-remote OAuth proxy pattern

Not all MCP clients support OAuth 2.0 — many support only API keys or static bearer tokens. The mcp-remote proxy pattern bridges this gap: a local proxy process handles the OAuth flow on behalf of the client and presents the MCP server to the client as if it accepts a simple bearer token.

# mcp-remote: local OAuth proxy for MCP clients that only support API keys
# The proxy handles device flow, token refresh, and presents a local socket
npx @modelcontextprotocol/mcp-remote \
  --server https://my-mcp-server.example.com/mcp \
  --oauth-client-id my-client-id \
  --scope "data:read data:write"

# Claude Desktop config: point at the local proxy, not the remote server
# The proxy handles OAuth; Claude Desktop sees a localhost connection

For MCP server authors, this means you can implement OAuth 2.0 on your server and know that clients using mcp-remote will work without any changes to the client. You do not need to maintain both API key and OAuth paths — implement OAuth 2.0 correctly and let mcp-remote handle clients that predate OAuth support.

Monitoring OAuth-protected MCP servers with AliveMCP

AliveMCP uses a Client Credentials grant (machine-to-machine, no device flow) for probing OAuth-protected MCP servers. The probe service account holds a long-lived client secret, exchanges it for a short-lived access token at probe time, and uses that token as a Bearer header. Token refresh is handled automatically by AliveMCP — you do not need to rotate the probe credential unless your client secret changes.

If the authorization server is unreachable, AliveMCP's probe will fail with a token acquisition error before even attempting to reach the MCP server. AliveMCP reports this distinctly from a server-level failure: "cannot obtain probe token — authorization server may be down." This lets you monitor the auth infrastructure dependency separately from the MCP server itself.

Related questions

Should I use PKCE for the device flow?

PKCE (Proof Key for Code Exchange, RFC 7636) is designed for the Authorization Code flow to prevent authorization code interception. The Device Authorization Grant has its own security model (the device code is short-lived and the user code is low-entropy by design) — PKCE does not apply to device flow directly. If you are implementing Authorization Code + PKCE for clients that do have a browser (some MCP desktop clients), always use PKCE regardless of whether the authorization server requires it — it adds no overhead and prevents code injection attacks.

How do I handle token expiry mid-session if I do not have a refresh token?

If the authorization server does not issue refresh tokens (some do not for device flow), your options are: (1) Use a longer access token TTL on the server side (acceptable for machine-to-machine; risky for user-delegated tokens). (2) Implement a server-side session token: at MCP session start, exchange the OAuth access token for a server-issued session token with a longer TTL — the OAuth token is validated once, and all subsequent tool calls use the server session token. The session token is invalidated when the user revokes the OAuth token. (3) Require re-authentication and accept that long-lived sessions will occasionally be interrupted.

Can I use OAuth 2.0 scopes directly as RBAC permissions in my tool handlers?

Yes — this is the recommended approach for MCP servers where the authorization server controls access. Map OAuth scopes to tool permissions in your TOOL_PERMISSIONS map (e.g. scope data:write is required for the create_resource tool). The OAuth token's scope claim becomes the source of truth for what the caller is permitted to do, and your RBAC enforcement layer checks it at tool-call time. This keeps permission management in the authorization server — revoke a scope there, and all MCP tool calls requiring that scope fail immediately on the next token refresh.

Further reading