Introduction

MCP defines OAuth 2.1 as the standard authentication mechanism for HTTP-transport servers. This provides a battle-tested framework for authenticating users and granting scoped access to MCP capabilities. Understanding the PKCE flow, token lifecycle, and metadata discovery is essential for deploying secure, user-facing MCP servers.

Key Concepts

  • OAuth 2.1: An evolution of OAuth 2.0 that mandates PKCE for all authorization code flows, deprecates the implicit grant, and tightens security defaults.
  • PKCE (Proof Key for Code Exchange): A mechanism that binds the authorization code to the client that requested it, preventing authorization code interception attacks. The client generates a random code_verifier, hashes it to create a code_challenge, sends the challenge with the auth request, and proves possession of the verifier when exchanging the code for tokens.
  • Authorization Server Metadata Discovery: MCP clients discover OAuth configuration by trying multiple well-known endpoints in priority order: first /.well-known/oauth-authorization-server (RFC 8414), then /.well-known/openid-configuration (OIDC Discovery 1.0). The metadata advertises the server's endpoints, supported grant types, token formats, and PKCE methods. If code_challenge_methods_supported is absent from the metadata, clients MUST refuse to proceed.
  • Dynamic Client Registration: An optional backward-compatibility mechanism (RFC 7591) that allows MCP clients to programmatically register with the authorization server without manual setup, receiving a client_id for subsequent flows. Included in MCP primarily for compatibility with earlier versions of the authorization spec.
  • Bearer Token: An access token sent in the HTTP Authorization header to authenticate API requests.

Real World Context

A team deploys a public MCP server that provides access to their project management API. Users interact via Claude Desktop, which acts as the MCP Client. When a user first connects, Claude Desktop discovers the server's OAuth metadata, registers dynamically, redirects the user to the login page, completes the PKCE flow, and obtains an access token. Subsequent tool invocations include this token in the Authorization header.

Deep Dive

Authorization Server Metadata Discovery

MCP clients discover OAuth configuration by attempting multiple well-known endpoints in priority order. They first try RFC 8414 (/.well-known/oauth-authorization-server), then fall back to OIDC Discovery (/.well-known/openid-configuration). Here is an example of the primary endpoint:

typescript
// Server: Expose metadata at well-known endpoint
app.get("/.well-known/oauth-authorization-server", (req, res) => {
  res.json({
    issuer: "https://mcp.example.com",
    authorization_endpoint: "https://mcp.example.com/authorize",
    token_endpoint: "https://mcp.example.com/token",
    registration_endpoint: "https://mcp.example.com/register",
    revocation_endpoint: "https://mcp.example.com/revoke",
    response_types_supported: ["code"],
    grant_types_supported: ["authorization_code", "refresh_token"],
    code_challenge_methods_supported: ["S256"],
    token_endpoint_auth_methods_supported: ["none"],
    scopes_supported: ["tools:read", "tools:write", "resources:read"]
  });
});

Clients MUST check code_challenge_methods_supported and verify that S256 is listed before proceeding. If this field is absent from the metadata entirely, clients MUST refuse to proceed with the authorization flow.

The PKCE Flow

PKCE prevents authorization code interception by binding the code to a cryptographic proof:

typescript
import crypto from "crypto";

// Step 1: Client generates a random code verifier
const codeVerifier = crypto.randomBytes(32).toString("base64url");

// Step 2: Client creates code challenge (SHA-256 hash of verifier)
const codeChallenge = crypto
  .createHash("sha256")
  .update(codeVerifier)
  .digest("base64url");

// Step 3: Client sends authorization request with challenge
const authUrl = new URL("https://mcp.example.com/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", "http://localhost:9999/callback");
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("scope", "tools:read tools:write");
// Redirect user to authUrl

// Step 4: After user authenticates, server redirects back with authorization code
// Client receives: http://localhost:9999/callback?code=AUTH_CODE

// Step 5: Client exchanges code + verifier for tokens
const tokenResponse = await fetch("https://mcp.example.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: authCode,
    redirect_uri: "http://localhost:9999/callback",
    client_id: clientId,
    code_verifier: codeVerifier // Proves possession
  })
});

const { access_token, refresh_token, expires_in } = await tokenResponse.json();

The server verifies the token request by hashing the provided code_verifier and comparing it to the code_challenge that was stored with the authorization code. If they don't match, the request is rejected.

Token Lifecycle

Access tokens are short-lived (typically 15-60 minutes). Refresh tokens are long-lived and used to obtain new access tokens without user interaction:

typescript
// Using bearer token in MCP requests
const headers = {
  "Authorization": `Bearer ${accessToken}`,
  "Content-Type": "application/json"
};

// Token refresh when access token expires
async function refreshAccessToken(refreshToken: string): Promise<TokenPair> {
  const response = await fetch("https://mcp.example.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
      client_id: clientId
    })
  });
  return response.json();
}

MCP clients should proactively refresh tokens before expiry to avoid interrupting tool invocations.

Common Pitfalls

  1. Using plain code challenge method: Always use S256 (SHA-256). The plain method provides no security benefit and should never be used in production.
  2. Storing tokens in logs: Access and refresh tokens must never appear in application logs. Redact them in error messages and audit trails.
  3. Skipping metadata discovery: Hardcoding OAuth endpoints is fragile. Always discover endpoints from the well-known metadata to handle server configuration changes gracefully.

Best Practices

  1. Mandate S256 PKCE: Both client and server should require S256. Reject authorization requests that use plain or omit PKCE entirely.
  2. Set short access token lifetimes: 15-30 minutes limits the window of exposure if a token is leaked.
  3. Implement token revocation: Provide a revocation endpoint so clients can invalidate tokens when sessions end or credentials are compromised.

Summary

  • MCP uses OAuth 2.1 with mandatory PKCE for HTTP-transport authentication
  • Clients discover OAuth endpoints by trying /.well-known/oauth-authorization-server (RFC 8414) first, then /.well-known/openid-configuration (OIDC) as a fallback
  • PKCE binds authorization codes to the requesting client via a cryptographic verifier/challenge pair
  • Access tokens are short-lived and sent as Bearer tokens; refresh tokens enable silent renewal
  • Dynamic client registration (RFC 7591) is an optional backward-compatibility mechanism for programmatic client setup

Code Examples

typescript
import crypto from "crypto";

// Generate PKCE pair
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
  .createHash("sha256")
  .update(codeVerifier)
  .digest("base64url");

// Authorization request includes challenge
const authParams = new URLSearchParams({
  response_type: "code",
  client_id: clientId,
  redirect_uri: redirectUri,
  code_challenge: codeChallenge,
  code_challenge_method: "S256",
  scope: "tools:read tools:write"
});

// Token exchange includes verifier
const tokenParams = new URLSearchParams({
  grant_type: "authorization_code",
  code: authCode,
  redirect_uri: redirectUri,
  client_id: clientId,
  code_verifier: codeVerifier
});
✓ Completed