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.
This architecture provides several enterprise benefits:
- Single sign-on (SSO): Users authenticate once through your IdP; MCP access inherits existing sessions
- Centralized policy: Conditional access, MFA requirements, and IP restrictions managed in IdP
- Audit trail: All token issuances logged by IdP; all tool invocations logged by MCP Server
- 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:
{
"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.
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:
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:
- Register the MCP Server app: Azure Portal > App Registrations > New Registration
- Expose an API: Set Application ID URI to
api://mcp-server-prod - Define scopes: Add
mcp:tools,mcp:resources,mcp:prompts - Register the client app: Create a separate registration for the MCP Client
- 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:
- Create Authorization Server: Security > API > Authorization Servers > Add
- Define scopes: Add
mcp:tools,mcp:resources,mcp:promptswith descriptions - Create access policy: Assign to MCP client applications
- Create rules: Set token lifetime (15 min for access, 8 hours for refresh)
- 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.
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:
- User authenticates and gets a token with
mcp:toolsscope - MCP Client sends tool call to MCP Server with user's token
- MCP Server exchanges user's token for a new token scoped to the downstream API
- MCP Server calls downstream API with the exchanged token
- Downstream API sees the original user's identity in the
subclaim
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:
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:
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:
# 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.