Enterprise MCP deployments require production-grade OAuth 2.1 integration to secure AI agent access to sensitive tools and resources. This guide provides a complete implementation walkthrough, from architecture decisions through deployment.

Unlike the MCP 2025-03-26 spec breakdown which analyzes protocol-level changes, this article is a hands-on deployment guide with working code you can adapt for your enterprise IdP.

Key Takeaways

  • MCP Servers in production must operate as Resource Servers that validate externally-issued tokens, never as Authorization Servers themselves
  • OAuth 2.1 with PKCE (S256) is mandatory for all MCP client types, eliminating implicit grants entirely
  • Enterprise IdPs (Azure AD, Okta, Auth0) integrate with MCP through standard OIDC discovery and JWKS validation
  • The On-Behalf-Of (OBO) flow enables MCP tools to call downstream APIs with delegated user identity
  • Token validation must include JWKS caching with forced-refresh fallback to handle key rotation without downtime
  • RFC 9728 Protected Resource Metadata publishes server requirements at /.well-known/oauth-protected-resource

Why Enterprise MCP Needs Proper Auth

Unsecured MCP Servers expose an enormous attack surface when deployed beyond localhost. Every MCP tool invocation is effectively a remote procedure call that can read files, query databases, execute code, or call external APIs. Without proper authentication and authorization, any client connecting to the MCP endpoint gains unrestricted access to these capabilities.

The threat model for remote MCP includes:

Attack Vector Impact Mitigation
Unauthorized tool invocation Data exfiltration, system modification OAuth token validation
Token theft/replay Session hijacking Short-lived tokens, sender-constrained tokens
Scope escalation Access to tools beyond granted permissions Fine-grained scope validation (mcp:tools:read)
Man-in-the-middle Token interception TLS 1.3, mTLS for service-to-service
Compromised client Lateral movement Per-tool scope authorization, audit logging

Compliance frameworks (SOC 2, ISO 27001, HIPAA) require that all machine-to-machine communication authenticates both parties and authorizes each operation. An AI agent invoking MCP tools on behalf of a user must carry verifiable proof of identity and delegated permissions.

The foundational principle: your MCP Server is a Resource Server, not an Authorization Server. Delegate authentication to your enterprise IdP and focus the MCP Server on token validation and scope enforcement.

Architecture: Separating Auth Server from Resource Server

The correct enterprise architecture separates concerns cleanly. Your Identity Provider (Azure AD, Okta, Auth0) handles user authentication, consent, and token issuance. Your MCP Server validates tokens and enforces scopes.

sequenceDiagram participant User as "User/Agent" participant Client as "MCP Client" participant IdP as "Identity Provider" participant MCP as "MCP Server (Resource Server)" participant API as "Downstream APIs" User->>Client: Initiate connection Client->>IdP: GET /.well-known/openid-configuration IdP-->>Client: Discovery document (endpoints, JWKS URI) Client->>IdP: Authorization request + PKCE challenge IdP->>User: Authenticate + consent screen User-->>IdP: Credentials + consent IdP-->>Client: Authorization code Client->>IdP: Token request + PKCE verifier IdP-->>Client: Access token + refresh token Client->>MCP: tools/list (Authorization: Bearer token) MCP->>IdP: Fetch JWKS (cached) MCP->>MCP: Validate signature, claims, scopes MCP-->>Client: Tool definitions Client->>MCP: tools/call (tool: query_database) MCP->>IdP: OBO token exchange (optional) MCP->>API: Query with delegated token API-->>MCP: Results MCP-->>Client: Tool response

This architecture provides several enterprise benefits:

  1. Single sign-on (SSO): Users authenticate once through your IdP; MCP access inherits existing sessions
  2. Centralized policy: Conditional access, MFA requirements, and IP restrictions managed in IdP
  3. Audit trail: All token issuances logged by IdP; all tool invocations logged by MCP Server
  4. Key management: IdP handles certificate rotation, JWKS publishing, and revocation

The MCP Server publishes its requirements via RFC 9728 Protected Resource Metadata at /.well-known/oauth-protected-resource:

json
{
  "resource": "https://mcp.yourcompany.com",
  "authorization_servers": ["https://login.microsoftonline.com/{tenant}/v2.0"],
  "scopes_supported": ["mcp:tools", "mcp:resources", "mcp:prompts"],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://docs.yourcompany.com/mcp-api"
}

Implementation Guide: OAuth 2.1 + PKCE Flow

The complete OAuth 2.1 flow for MCP clients requires PKCE with S256 challenge method. Here is a production-ready TypeScript implementation for the client side that handles the full authorization code flow with PKCE.

typescript
import crypto from 'crypto';
import { URL, URLSearchParams } from 'url';

interface OAuthConfig {
  clientId: string;
  redirectUri: string;
  issuer: string;
  scopes: string[];
}

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

interface OIDCDiscovery {
  authorization_endpoint: string;
  token_endpoint: string;
  jwks_uri: string;
  registration_endpoint?: string;
  scopes_supported: string[];
}

class MCPOAuthClient {
  private config: OAuthConfig;
  private discovery: OIDCDiscovery | null = null;
  private codeVerifier: string = '';

  constructor(config: OAuthConfig) {
    this.config = config;
  }

  async initialize(): Promise<void> {
    const discoveryUrl = `${this.config.issuer}/.well-known/openid-configuration`;
    const response = await fetch(discoveryUrl);
    if (!response.ok) {
      throw new Error(`Discovery failed: ${response.status}`);
    }
    this.discovery = await response.json();
  }

  generatePKCE(): { verifier: string; challenge: string } {
    // Generate 43-128 character random verifier (RFC 7636)
    this.codeVerifier = crypto.randomBytes(32)
      .toString('base64url')
      .slice(0, 64);

    // S256 challenge: BASE64URL(SHA256(verifier))
    const challenge = crypto
      .createHash('sha256')
      .update(this.codeVerifier)
      .digest('base64url');

    return { verifier: this.codeVerifier, challenge };
  }

  buildAuthorizationUrl(): string {
    if (!this.discovery) throw new Error('Call initialize() first');

    const { challenge } = this.generatePKCE();

    const params = new URLSearchParams({
      response_type: 'code',
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      scope: this.config.scopes.join(' '),
      code_challenge: challenge,
      code_challenge_method: 'S256',
      state: crypto.randomBytes(16).toString('hex'),
    });

    return `${this.discovery.authorization_endpoint}?${params.toString()}`;
  }

  async exchangeCode(authorizationCode: string): Promise<TokenResponse> {
    if (!this.discovery) throw new Error('Call initialize() first');

    const body = new URLSearchParams({
      grant_type: 'authorization_code',
      code: authorizationCode,
      redirect_uri: this.config.redirectUri,
      client_id: this.config.clientId,
      code_verifier: this.codeVerifier,
    });

    const response = await fetch(this.discovery.token_endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString(),
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Token exchange failed: ${error}`);
    }

    return response.json();
  }

  async refreshToken(refreshToken: string): Promise<TokenResponse> {
    if (!this.discovery) throw new Error('Call initialize() first');

    const body = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: this.config.clientId,
      scope: this.config.scopes.join(' '),
    });

    const response = await fetch(this.discovery.token_endpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString(),
    });

    if (!response.ok) {
      throw new Error(`Token refresh failed: ${response.status}`);
    }

    return response.json();
  }
}

// Usage with MCP scopes
const client = new MCPOAuthClient({
  clientId: 'mcp-agent-client-001',
  redirectUri: 'http://localhost:3000/callback',
  issuer: 'https://login.microsoftonline.com/{tenant}/v2.0',
  scopes: ['openid', 'profile', 'mcp:tools', 'mcp:resources'],
});

async function connectToMCPServer() {
  await client.initialize();
  const authUrl = client.buildAuthorizationUrl();
  console.log(`Authorize at: ${authUrl}`);
  // After redirect callback with ?code=xxx
  // const tokens = await client.exchangeCode(code);
}

This client implementation handles dynamic discovery, PKCE generation with S256, and token lifecycle management. You can use the JWT Generator to inspect and debug the tokens returned by your IdP during development.

The OAuth 2.1 + PKCE Authorization Flow

The following diagram illustrates the complete message exchange during the OAuth 2.1 PKCE flow between an MCP Client and your enterprise IdP:

flowchart TD A["MCP Client starts"] --> B["Generate PKCE verifier (random 32 bytes)"] B --> C["Compute S256 challenge = BASE64URL(SHA256(verifier))"] C --> D["Discover IdP endpoints via OIDC discovery"] D --> E["Redirect user to authorization endpoint with challenge"] E --> F{"User authenticates?"} F -->|"Yes"| G["IdP returns authorization code"] F -->|"No"| H["Flow terminates"] G --> I["Exchange code + verifier at token endpoint"] I --> J{"IdP verifies SHA256(verifier) == challenge?"} J -->|"Match"| K["IdP issues access_token + refresh_token"] J -->|"No match"| L["Reject: invalid_grant"] K --> M["Client sends MCP request with Bearer token"] M --> N["MCP Server validates token via JWKS"] N --> O{"Token valid + scopes sufficient?"} O -->|"Yes"| P["Execute tool and return result"] O -->|"No"| Q["Return 401/403"]

The critical security property of PKCE is that even if an attacker intercepts the authorization code during the redirect, they cannot exchange it without the original code verifier that never left the client.

IdP Integration Patterns

Enterprise deployments typically standardize on one IdP. Each provider has slightly different configuration requirements for MCP Server integration.

Comparison: Azure AD vs. Okta vs. Auth0

Feature Azure AD (Entra ID) Okta Auth0
App registration App Registration > API permissions Applications > API Applications > APIs
Custom scopes Expose an API > Add scope Authorization Servers > Scopes APIs > Permissions
OIDC discovery /{tenant}/v2.0/.well-known/openid-configuration /{authServerId}/.well-known/openid-configuration /.well-known/openid-configuration
OBO support Native (urn:ietf:params:oauth:grant-type:jwt-bearer) Token exchange (RFC 8693) Token exchange extension
JWKS endpoint /{tenant}/discovery/v2.0/keys /{authServerId}/v1/keys /.well-known/jwks.json
MCP scope format api://{clientId}/mcp:tools mcp:tools mcp:tools
Dynamic client registration Not supported natively Supported via API Supported via Management API
Token lifetime (default) 60-90 min (configurable) 60 min 86400s (configurable)
Conditional access Full (device compliance, location, risk) Limited (network zones, MFA) Rules + Actions
mTLS support Certificate-based auth Mutual TLS (custom) mTLS add-on

Azure AD Configuration Example

For Azure AD (now Microsoft Entra ID), register the MCP Server as an API:

  1. Register the MCP Server app: Azure Portal > App Registrations > New Registration
  2. Expose an API: Set Application ID URI to api://mcp-server-prod
  3. Define scopes: Add mcp:tools, mcp:resources, mcp:prompts
  4. Register the client app: Create a separate registration for the MCP Client
  5. Grant permissions: Client app > API Permissions > Add api://mcp-server-prod/mcp:tools

The aud claim in tokens will be api://mcp-server-prod, which your MCP Server must validate.

Okta Configuration Example

With Okta, create a custom Authorization Server:

  1. Create Authorization Server: Security > API > Authorization Servers > Add
  2. Define scopes: Add mcp:tools, mcp:resources, mcp:prompts with descriptions
  3. Create access policy: Assign to MCP client applications
  4. Create rules: Set token lifetime (15 min for access, 8 hours for refresh)
  5. Register client application: Applications > Create App Integration > OIDC

The audience (aud) will be the Authorization Server's audience value you configured.

Token Validation & JWKS Middleware

The MCP Server must validate every incoming token before executing any tool. This Python implementation demonstrates production-grade JWKS-based validation with caching and rotation handling.

python
import time
import json
import hashlib
from typing import Optional
from dataclasses import dataclass, field
from urllib.request import urlopen

import jwt
from jwt import PyJWKClient, PyJWK
from jwt.exceptions import InvalidTokenError, PyJWKClientError


@dataclass
class JWKSCache:
    """JWKS cache with TTL and forced-refresh support."""
    jwks_uri: str
    cache_ttl: int = 300  # 5 minutes
    _client: Optional[PyJWKClient] = field(default=None, init=False)
    _last_fetch: float = field(default=0, init=False)
    _keys: dict = field(default_factory=dict, init=False)

    def _should_refresh(self) -> bool:
        return time.time() - self._last_fetch > self.cache_ttl

    def _fetch_keys(self, force: bool = False) -> None:
        if not force and not self._should_refresh():
            return
        self._client = PyJWKClient(
            self.jwks_uri,
            cache_keys=True,
            lifespan=self.cache_ttl,
        )
        self._last_fetch = time.time()

    def get_signing_key(self, token: str, force_refresh: bool = False) -> PyJWK:
        self._fetch_keys(force=force_refresh)
        try:
            return self._client.get_signing_key_from_jwt(token)
        except PyJWKClientError:
            if not force_refresh:
                # Key rotation: force refresh and retry once
                return self.get_signing_key(token, force_refresh=True)
            raise


@dataclass
class TokenValidationConfig:
    """Configuration for MCP token validation."""
    issuer: str
    audience: str
    required_scopes: list[str] = field(default_factory=list)
    algorithms: list[str] = field(default_factory=lambda: ["RS256"])
    clock_skew: int = 30  # seconds


class MCPTokenValidator:
    """Production token validator for MCP Server middleware."""

    def __init__(self, config: TokenValidationConfig, jwks_uri: str):
        self.config = config
        self.jwks_cache = JWKSCache(jwks_uri=jwks_uri)

    def validate_token(self, token: str) -> dict:
        """
        Validate an access token and return decoded claims.
        Raises InvalidTokenError on failure.
        """
        # Step 1: Get signing key (with cache + rotation handling)
        signing_key = self.jwks_cache.get_signing_key(token)

        # Step 2: Decode and validate standard claims
        try:
            claims = jwt.decode(
                token,
                signing_key.key,
                algorithms=self.config.algorithms,
                issuer=self.config.issuer,
                audience=self.config.audience,
                options={
                    "verify_exp": True,
                    "verify_iat": True,
                    "verify_nbf": True,
                    "require": ["exp", "iat", "sub", "iss", "aud"],
                },
                leeway=self.config.clock_skew,
            )
        except InvalidTokenError as e:
            raise InvalidTokenError(f"Token validation failed: {e}")

        # Step 3: Validate scopes
        token_scopes = claims.get("scp", "").split() or claims.get("scope", "").split()
        if self.config.required_scopes:
            missing = set(self.config.required_scopes) - set(token_scopes)
            if missing:
                raise InvalidTokenError(
                    f"Insufficient scopes. Missing: {missing}"
                )

        return claims

    def validate_request(self, authorization_header: str) -> dict:
        """Extract and validate Bearer token from Authorization header."""
        if not authorization_header.startswith("Bearer "):
            raise InvalidTokenError("Missing Bearer prefix")

        token = authorization_header[7:]
        return self.validate_token(token)


# Usage in MCP Server middleware
validator = MCPTokenValidator(
    config=TokenValidationConfig(
        issuer="https://login.microsoftonline.com/{tenant}/v2.0",
        audience="api://mcp-server-prod",
        required_scopes=["mcp:tools"],
    ),
    jwks_uri="https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys",
)

# In your MCP request handler:
# claims = validator.validate_request(request.headers["Authorization"])
# user_id = claims["sub"]
# proceed with tool execution...

This implementation handles the critical JWKS rotation scenario: when a validation fails because the IdP rotated keys, it force-refreshes the JWKS cache and retries before rejecting the token. This prevents downtime during routine key rotations.

For debugging token claims during development, use the JSON Formatter to inspect decoded JWT payloads.

Advanced: On-Behalf-Of (OBO) Flow for Downstream APIs

The On-Behalf-Of flow solves a critical enterprise problem: when an MCP tool needs to call a downstream API (database, SaaS platform, internal service) using the original user's identity. Without OBO, the MCP Server would need service-level access to all downstream resources, violating least-privilege principles.

The OBO flow works as follows:

  1. User authenticates and gets a token with mcp:tools scope
  2. MCP Client sends tool call to MCP Server with user's token
  3. MCP Server exchanges user's token for a new token scoped to the downstream API
  4. MCP Server calls downstream API with the exchanged token
  5. Downstream API sees the original user's identity in the sub claim
typescript
interface OBOTokenRequest {
  grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer';
  client_id: string;
  client_secret: string;
  assertion: string; // The user's access token
  scope: string; // Target API scope
  requested_token_use: 'on_behalf_of';
}

class OBOTokenExchanger {
  private tokenEndpoint: string;
  private clientId: string;
  private clientSecret: string;
  private tokenCache: Map<string, { token: string; expiry: number }> = new Map();

  constructor(tokenEndpoint: string, clientId: string, clientSecret: string) {
    this.tokenEndpoint = tokenEndpoint;
    this.clientId = clientId;
    this.clientSecret = clientSecret;
  }

  async exchangeToken(userToken: string, targetScope: string): Promise<string> {
    // Check cache first (keyed by user token hash + scope)
    const cacheKey = this.computeCacheKey(userToken, targetScope);
    const cached = this.tokenCache.get(cacheKey);
    if (cached && cached.expiry > Date.now() + 60000) {
      return cached.token;
    }

    const body = new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      client_id: this.clientId,
      client_secret: this.clientSecret,
      assertion: userToken,
      scope: targetScope,
      requested_token_use: 'on_behalf_of',
    });

    const response = await fetch(this.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString(),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(
        `OBO exchange failed: ${error.error_description || error.error}`
      );
    }

    const result = await response.json();

    // Cache the exchanged token
    this.tokenCache.set(cacheKey, {
      token: result.access_token,
      expiry: Date.now() + result.expires_in * 1000,
    });

    return result.access_token;
  }

  private computeCacheKey(token: string, scope: string): string {
    const crypto = require('crypto');
    return crypto
      .createHash('sha256')
      .update(`${token}:${scope}`)
      .digest('hex');
  }
}

// Usage in MCP tool handler
const oboExchanger = new OBOTokenExchanger(
  'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token',
  process.env.MCP_CLIENT_ID!,
  process.env.MCP_CLIENT_SECRET!,
);

async function handleDatabaseQuery(userToken: string, query: string) {
  // Exchange user's MCP token for a database API token
  const dbToken = await oboExchanger.exchangeToken(
    userToken,
    'api://database-service/.default'
  );

  // Call downstream database API with delegated identity
  const result = await fetch('https://db-api.internal/query', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${dbToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ sql: query }),
  });

  return result.json();
}

Key OBO considerations for production:

  • Token caching: OBO exchanges are expensive (network call to IdP). Cache exchanged tokens by user+scope with TTL based on expires_in.
  • Consent propagation: The first OBO exchange for a user+scope may require admin consent in Azure AD. Pre-consent via admin consent flow.
  • Chain depth: Azure AD supports OBO chains up to 5 levels deep. Design your architecture to minimize chain depth.
  • Error handling: OBO can fail due to consent issues, scope mismatches, or expired upstream tokens. Surface clear errors to the MCP Client.

Security Hardening Checklist

Production MCP deployments require defense-in-depth. This checklist covers the critical security controls beyond basic OAuth.

TLS and Transport Security

Control Requirement Implementation
TLS version TLS 1.3 minimum Configure server to reject TLS 1.2 and below
Certificate validation Strict hostname verification No self-signed certs in production
mTLS (optional) Client certificate authentication For service-to-service MCP calls
HSTS Strict-Transport-Security header max-age=31536000; includeSubDomains

Token Security

Control Requirement Implementation
Access token lifetime 15 minutes maximum Configure in IdP token policy
Refresh token One-time-use, rotated on each use IdP enforces; client stores new refresh token
Token binding Sender-constrained (DPoP or mTLS) Validate cnf claim when present
Audience validation Exact match required Reject tokens issued for other APIs
Issuer validation Exact match against known IdP Reject tokens from unexpected issuers

Scope and Authorization

MCP defines three base scopes. Implement them at the tool level:

typescript
enum MCPScope {
  TOOLS = 'mcp:tools',        // Invoke tools
  RESOURCES = 'mcp:resources', // Access resources
  PROMPTS = 'mcp:prompts',    // Use prompt templates
}

// Fine-grained scope extensions (custom)
enum MCPToolScope {
  TOOLS_READ = 'mcp:tools:read',     // List/describe tools only
  TOOLS_EXECUTE = 'mcp:tools:execute', // Actually invoke tools
  TOOLS_ADMIN = 'mcp:tools:admin',    // Manage tool registrations
}

function authorizeToolCall(
  claims: Record<string, unknown>,
  toolName: string
): boolean {
  const scopes = (claims.scp as string || '').split(' ');
  
  // Check base scope
  if (!scopes.includes(MCPScope.TOOLS) && 
      !scopes.includes(MCPToolScope.TOOLS_EXECUTE)) {
    return false;
  }
  
  // Check tool-specific authorization (from custom claims or policy)
  const allowedTools = claims['mcp_allowed_tools'] as string[] || [];
  if (allowedTools.length > 0 && !allowedTools.includes(toolName)) {
    return false;
  }
  
  return true;
}

Rate Limiting and Abuse Prevention

Implement per-client rate limiting based on the client_id claim:

Resource Limit Window
Tool invocations 100 requests per minute
Resource reads 500 requests per minute
Token refresh 10 requests per hour
Failed auth attempts 5 attempts per 5 minutes (then block 15 min)

Production Deployment

A production MCP Server deployment combines the OAuth middleware, tool handlers, and infrastructure configuration. Here is a Docker Compose configuration for deploying an MCP Server with nginx as TLS terminator:

yaml
version: "3.9"

services:
  mcp-server:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      - NODE_ENV=production
      - MCP_PORT=8080
      - OAUTH_ISSUER=https://login.microsoftonline.com/${TENANT_ID}/v2.0
      - OAUTH_AUDIENCE=api://mcp-server-prod
      - OAUTH_JWKS_URI=https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys
      - OAUTH_REQUIRED_SCOPES=mcp:tools
      - OBO_CLIENT_ID=${MCP_CLIENT_ID}
      - OBO_CLIENT_SECRET=${MCP_CLIENT_SECRET}
      - RATE_LIMIT_RPM=100
      - LOG_LEVEL=info
    ports:
      - "8080:8080"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"
      restart_policy:
        condition: on-failure
        max_attempts: 3

  nginx:
    image: nginx:1.25-alpine
    ports:
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - mcp-server

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

volumes:
  redis-data:

The nginx configuration enforces TLS 1.3 and forwards requests to the MCP Server:

yaml
# nginx.conf (relevant snippet)
server:
  listen: 443 ssl
  server_name: mcp.yourcompany.com
  
  ssl_certificate: /etc/nginx/certs/fullchain.pem
  ssl_certificate_key: /etc/nginx/certs/privkey.pem
  ssl_protocols: TLSv1.3
  ssl_prefer_server_ciphers: "off"
  
  add_header: Strict-Transport-Security "max-age=31536000; includeSubDomains" always
  
  location /:
    proxy_pass: http://mcp-server:8080
    proxy_set_header: Host $host
    proxy_set_header: X-Real-IP $remote_addr
    proxy_set_header: X-Forwarded-For $proxy_add_x_forwarded_for
    proxy_set_header: X-Forwarded-Proto $scheme
    
    # SSE/Streaming support
    proxy_buffering: "off"
    proxy_cache: "off"
    proxy_read_timeout: 86400s

Redis is used for rate limiting state and token cache. In Kubernetes deployments, replace Docker Compose with Helm charts and use cert-manager for TLS certificate automation.

For validating your deployment configuration files, the YAML to JSON converter helps verify syntax during development.

Common Pitfalls & Solutions

Teams deploying MCP with OAuth in production consistently encounter these issues. Learn from their mistakes:

Pitfall Symptom Solution
JWKS cache staleness 401 errors after IdP key rotation Implement force-refresh on validation failure, 5-min TTL
Token audience mismatch All tokens rejected as invalid Ensure aud matches exactly what IdP issues (including api:// prefix)
PKCE verifier lost Token exchange returns invalid_grant Persist verifier across redirect; use secure session storage
Refresh token replay invalid_grant on second refresh Store new refresh token from each refresh response; one-time use
Clock skew Tokens rejected as expired immediately Add 30s leeway to exp validation; sync NTP
CORS blocking discovery Client cannot fetch OIDC configuration Configure IdP CORS or proxy discovery through your domain
Scope format differences Scopes not recognized by IdP Azure AD uses api://{id}/scope; Okta uses bare scope
OBO consent not granted interaction_required error Pre-grant admin consent for downstream API permissions
Token too large for headers HTTP 431 errors Reduce claims; use access token for auth, separate userinfo call for profile
mTLS certificate expiry Sudden connection failures Monitor cert expiry; automate rotation with cert-manager

Understanding how OAuth token flows work at the protocol level helps diagnose these issues faster. For teams building their own LLM-powered agents, the MCP protocol deep dive provides the foundational context needed to reason about these authentication flows.

Monitoring and Observability

Production deployments need comprehensive observability. Track these metrics:

Metric Purpose Alert Threshold
mcp_auth_success_total Successful authentications N/A (baseline)
mcp_auth_failure_total Failed authentications by reason > 50/min
mcp_token_refresh_total Token refresh operations Sudden spike = possible abuse
mcp_jwks_refresh_total JWKS cache refreshes > 1/min = rotation issue
mcp_obo_exchange_latency_ms OBO token exchange time p99 > 2000ms
mcp_tool_invocation_total Tool calls by tool name + user N/A (audit)
mcp_rate_limit_hit_total Rate limit rejections > 10/min per client

For teams evaluating different MCP server implementation languages, the Node.js vs Go MCP performance comparison covers throughput characteristics relevant to token validation overhead.

Frequently Asked Questions

How does OAuth 2.1 differ from OAuth 2.0 in MCP deployments?

OAuth 2.1 mandates PKCE with S256 for all clients (public and confidential), eliminates the implicit grant flow entirely, enforces sender-constrained tokens, and requires exact redirect URI matching without wildcards. In MCP deployments specifically, this means every agent client must implement the PKCE challenge/verifier exchange regardless of whether it can keep a client secret. Refresh tokens become one-time-use with automatic rotation, preventing replay attacks if a token is intercepted.

Can I use my existing Azure AD or Okta tenant with MCP Servers?

Yes, and this is the recommended approach. Your MCP Server operates as a Resource Server that validates tokens issued by your existing IdP. Register the MCP Server as an API application in your IdP, configure custom scopes (mcp:tools, mcp:resources, mcp:prompts), and the server validates incoming tokens by fetching the JWKS from your IdP's published endpoint. No migration or new IdP deployment is needed.

What is the On-Behalf-Of (OBO) flow and when do I need it?

OBO enables an MCP Server to call downstream APIs using the original user's delegated identity. When a user invokes an MCP tool that needs to access their personal data in a downstream service (SharePoint files, Salesforce records, database rows), OBO exchanges the user's MCP token for a new token scoped to that downstream API. The downstream service sees the original user's sub claim, enabling row-level security and proper audit trails without giving the MCP Server blanket access.

How do I handle JWKS key rotation without downtime?

Implement a JWKS cache with a 5-minute TTL and a critical fallback mechanism: when token validation fails with a signature error, force-refresh the JWKS cache before rejecting. This handles the window between IdP publishing new keys and your cache updating. Always validate the kid (Key ID) JWT header to select the correct key from the set, as IdPs publish multiple keys during rotation periods. Monitor the mcp_jwks_refresh_total metric to detect abnormal rotation patterns.

What security measures are required for production MCP deployments?

Production MCP requires multiple layers: TLS 1.3 enforcement (reject lower versions), PKCE with S256 only (never plain), access token lifetime capped at 15 minutes, one-time refresh tokens with rotation, scope-based access control at the tool level, per-client rate limiting, and RFC 9728 Protected Resource Metadata publication. For service-to-service scenarios, add mTLS for mutual authentication. All tool invocations must be logged with the authenticated sub and client_id claims for audit compliance.