OAuth 2.0 with MCP: Authentication for Remote Servers

Remote MCP servers exposed over HTTP need authentication. The MCP specification recommends OAuth 2.0 with PKCE for browser-based and CLI clients. This lesson covers the complete OAuth 2.0 flow for MCP: the authorization server setup, the protected resource server, the client-side PKCE dance, and the token refresh lifecycle. When you finish this lesson your MCP server will reject unauthenticated connections and correctly scope what each authenticated client can access.

OAuth 2.0 PKCE flow diagram for MCP server authentication showing authorization code flow with client tokens dark
MCP over HTTP uses OAuth 2.0 Authorization Code + PKCE: no client secrets, no password flow.

Why OAuth 2.0 for MCP

MCP servers are effectively APIs. They expose tools, resources, and prompts that can access sensitive data, execute code, or modify state. Without authentication, any client that knows the server URL can use those capabilities. OAuth 2.0 provides:

  • Authentication: Only clients that obtain a valid token can connect
  • Authorization: Tokens can carry scopes that limit which tools and resources a client can access
  • Delegation: A human user can authorize a client to act on their behalf without sharing passwords
  • Revocation: Access can be revoked immediately by invalidating the token

The MCP OAuth Flow

The flow follows OAuth 2.0 Authorization Code + PKCE (RFC 7636):

// Step 1: Client generates PKCE code verifier and challenge
import crypto from 'node:crypto';

function generatePkce() {
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
  return { verifier, challenge };
}

// Step 2: Client redirects user to authorization URL
function buildAuthUrl(config, pkce, state) {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: config.scopes.join(' '),
    state,
    code_challenge: pkce.challenge,
    code_challenge_method: 'S256',
  });
  return `${config.authorizationEndpoint}?${params}`;
}

// Step 3: User authorizes, gets redirected back with code
// Step 4: Client exchanges code for tokens
async function exchangeCode(config, code, pkce) {
  const response = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: config.clientId,
      redirect_uri: config.redirectUri,
      code,
      code_verifier: pkce.verifier,
    }),
  });
  if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`);
  return response.json();
}
PKCE code verifier challenge generation flow SHA256 hashing base64url encoding diagram dark security
PKCE prevents authorization code interception: the challenge proves ownership without a client secret.

Protecting an MCP Server with Bearer Tokens

import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamable-http.js';

const app = express();

// Token validation middleware
async function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'unauthorized', error_description: 'Bearer token required' });
  }
  const token = authHeader.slice(7);
  try {
    // Validate with your auth server (introspection endpoint, or local JWT verification)
    const claims = await validateToken(token);
    req.auth = claims;  // { sub, scope, exp }
    next();
  } catch {
    res.status(401).json({ error: 'invalid_token', error_description: 'Token is invalid or expired' });
  }
}

// Apply auth to the MCP endpoint
app.use('/mcp', requireAuth);

app.post('/mcp', async (req, res) => {
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
  const server = buildMcpServer(req.auth);  // Pass auth claims to server for per-user tool filtering
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

JWT Validation (Self-Contained Tokens)

import { createRemoteJWKSet, jwtVerify } from 'jose';

// Cache the JWKS (JSON Web Key Set) fetched from your auth server
const JWKS = createRemoteJWKSet(new URL('https://auth.yourcompany.com/.well-known/jwks.json'));

async function validateToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.yourcompany.com',
    audience: 'mcp-server',
  });
  return payload;
}

Token Refresh Lifecycle in MCP Clients

class TokenManager {
  #accessToken = null;
  #refreshToken = null;
  #expiresAt = 0;

  setTokens({ access_token, refresh_token, expires_in }) {
    this.#accessToken = access_token;
    this.#refreshToken = refresh_token;
    this.#expiresAt = Date.now() + (expires_in - 60) * 1000;  // 60s buffer
  }

  async getAccessToken(config) {
    if (Date.now() < this.#expiresAt) return this.#accessToken;
    if (!this.#refreshToken) throw new Error('Session expired - re-authentication required');
    
    const response = await fetch(config.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: config.clientId,
        refresh_token: this.#refreshToken,
      }),
    });
    if (!response.ok) throw new Error('Token refresh failed');
    this.setTokens(await response.json());
    return this.#accessToken;
  }
}

// Use it in the MCP transport
const tokenManager = new TokenManager();
const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL), {
  requestInit: async () => ({
    headers: { Authorization: `Bearer ${await tokenManager.getAccessToken(oauthConfig)}` },
  }),
});

Using an Existing Auth Provider

For production, use an existing OAuth 2.0 provider rather than building your own authorization server:

  • Auth0: Managed OAuth + JWKS endpoint, simple Node.js SDK
  • Google OAuth 2.0: For Google Workspace integrations
  • GitHub OAuth: For developer-facing MCP tools
  • Keycloak: Self-hosted, enterprise IAM with fine-grained authorization

Common Authentication Failures

  • Returning 403 instead of 401: 401 means “not authenticated” (present credentials), 403 means “authenticated but not authorized” (wrong scope). Use the right code or clients will not know to re-authenticate.
  • Not validating the audience claim: A token issued for your user service should not work on your MCP server. Always validate aud matches your server’s identifier.
  • Not handling token expiry during long tool calls: An MCP tool that takes 5 minutes to execute may outlive a short-lived access token. Use the token manager pattern with a generous buffer.
  • Logging tokens: Never log full tokens in application logs. Log the token’s sub (subject) and jti (token ID) instead for traceability without exposure.

What to Build Next

  • Add Bearer token validation to your existing Streamable HTTP MCP server. Test it with both valid and expired tokens.
  • Implement a simple OAuth client using PKCE that stores tokens in a local file and refreshes them automatically.

nJoy 😉

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.