← All articles

Enterprise-Managed Authorization for MCP: Complete Guide

July 6, 2026·24 min read·MCPForge

Enterprise-Managed Authorization for MCP: Complete Guide

Most teams deploying MCP servers in enterprise environments run straight into the same wall: individual OAuth apps per server, manually distributed tokens, no centralized revocation, and zero visibility into who called what tool when. Standard OAuth in MCP solves authentication — but it doesn't solve governance.

Enterprise-Managed Authorization (EMA) is the pattern that closes this gap. It centralizes authorization through your corporate identity provider, gives security teams policy control over MCP tool access, and produces the audit trail that compliance requires. This guide covers exactly how it works, how to implement it with Okta (and other IdPs), how Claude and VS Code consume it, and what a production-grade deployment actually looks like.


What Enterprise-Managed Authorization Actually Means

EMA is not a separate protocol — it's an architectural pattern built on top of OAuth 2.0 and OIDC. The core idea is straightforward:

The enterprise, not the MCP server developer, controls who can access what.

In practice this means:

  • The MCP server advertises your corporate IdP as its authorization server in its discovery metadata
  • Users authenticate with their existing SSO credentials — no new passwords, no separate accounts
  • The IdP issues scoped JWT access tokens based on the user's group memberships and enterprise policies
  • The MCP server validates tokens on every tool call and enforces fine-grained access control
  • All of this is auditable through your existing SIEM infrastructure

The contrast with standard MCP OAuth is significant:

DimensionStandard MCP OAuthEnterprise-Managed Authorization
Authorization serverPer-server OAuth appCorporate IdP (Okta, Entra ID, etc.)
Token issuanceServer-specificCentrally managed
User onboardingManual token distributionAutomatic via SSO
Access controlServer-levelGroup/role/policy-level
RevocationPer-serverInstant, centralized
Audit loggingOptional, per-serverCentralized, SIEM-integrated
Compliance scopeNarrowEnterprise-wide
User experienceConfigure tokens manuallyBrowser SSO, zero touch

The moment you have more than a handful of MCP servers, or the moment a security team asks "who accessed the database tool on Tuesday at 3pm?", standard OAuth doesn't scale. EMA does.


The Authentication Flow in Detail

Understanding the exact token flow is essential before implementing anything. EMA uses OAuth 2.0 Authorization Code Flow with PKCE (RFC 7636), which is mandatory for any public client like a desktop application or browser extension.

┌─────────────────────────────────────────────────────────────────┐
│                    EMA Authorization Flow                        │
└─────────────────────────────────────────────────────────────────┘

  MCP Client           MCP Server            Enterprise IdP
  (Claude/VS Code)     (Resource Server)     (Okta/Entra ID)
       │                     │                      │
       │  1. Discover server │                      │
       │──────────────────→  │                      │
       │                     │                      │
       │  2. Return metadata (authorization_endpoint, scopes)
       │  ←──────────────────│                      │
       │                     │                      │
       │  3. Build PKCE code_verifier + code_challenge          │
       │                     │                      │
       │  4. Redirect user to IdP authorization URL            │
       │─────────────────────────────────────────→  │
       │                     │                      │
       │         5. User authenticates (SSO/MFA)    │
       │                     │                      │
       │  6. IdP returns authorization_code         │
       │  ←─────────────────────────────────────────│
       │                     │                      │
       │  7. Exchange code + code_verifier for tokens          │
       │─────────────────────────────────────────→  │
       │                     │                      │
       │  8. IdP returns access_token + id_token    │
       │  ←─────────────────────────────────────────│
       │                     │                      │
       │  9. Call MCP tool with Bearer access_token │
       │──────────────────→  │                      │
       │                     │                      │
       │  10. Validate JWT (iss, aud, exp, scope)   │
       │                     │──────────────────→   │
       │                     │  (JWKS endpoint)     │
       │                     │  ←────────────────── │
       │                     │                      │
       │  11. Return tool result (or 403 if denied) │
       │  ←──────────────────│                      │

The critical insight is step 10: your MCP server never issues tokens. It only validates them. The server becomes a pure resource server in OAuth terms. This separation of concerns is what makes EMA auditable and governable.


Server Discovery: The Entry Point

EMA only works if the MCP client can discover the enterprise authorization endpoint automatically. This happens through a discovery document your server exposes.

Per the MCP specification, servers should expose metadata at /.well-known/oauth-authorization-server. For MCP-specific discovery, some clients also check /.well-known/mcp.

Here's what a complete discovery document looks like for an Okta-backed MCP server:

json
{
  "issuer": "https://mcp.internal.yourcompany.com",
  "authorization_endpoint": "https://yourcompany.okta.com/oauth2/v1/authorize",
  "token_endpoint": "https://yourcompany.okta.com/oauth2/v1/token",
  "jwks_uri": "https://yourcompany.okta.com/oauth2/v1/keys",
  "userinfo_endpoint": "https://yourcompany.okta.com/oauth2/v1/userinfo",
  "scopes_supported": [
    "openid",
    "profile",
    "email",
    "mcp:tools:read",
    "mcp:tools:execute",
    "mcp:tools:admin"
  ],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "mcp_resource_server": "https://mcp.internal.yourcompany.com"
}

The authorization_endpoint and token_endpoint both point to Okta — not your MCP server. The MCP client reads this, constructs the PKCE flow, and sends the user to Okta for authentication. Your server stays out of the credential business entirely.

Expose this in your Node.js/TypeScript MCP server:

typescript
import express from 'express';

const app = express();

const OKTA_DOMAIN = process.env.OKTA_DOMAIN; // e.g. yourcompany.okta.com
const SERVER_ISSUER = process.env.MCP_SERVER_URL; // e.g. https://mcp.internal.yourcompany.com

app.get('/.well-known/oauth-authorization-server', (req, res) => {
  res.json({
    issuer: SERVER_ISSUER,
    authorization_endpoint: `https://${OKTA_DOMAIN}/oauth2/v1/authorize`,
    token_endpoint: `https://${OKTA_DOMAIN}/oauth2/v1/token`,
    jwks_uri: `https://${OKTA_DOMAIN}/oauth2/v1/keys`,
    userinfo_endpoint: `https://${OKTA_DOMAIN}/oauth2/v1/userinfo`,
    scopes_supported: [
      'openid', 'profile', 'email',
      'mcp:tools:read', 'mcp:tools:execute', 'mcp:tools:admin'
    ],
    response_types_supported: ['code'],
    grant_types_supported: ['authorization_code', 'refresh_token'],
    code_challenge_methods_supported: ['S256'],
    token_endpoint_auth_methods_supported: ['none'],
  });
});

Want to analyze your API security?

Import your OpenAPI spec and generate a Security Report automatically.

Okta Integration: Step-by-Step

Okta is the most common enterprise IdP for MCP deployments. Here's the complete setup.

Step 1 — Create an Okta Application

In your Okta admin console:

  1. Applications → Create App Integration
  2. Choose OIDC — OpenID Connect and Single-Page Application (or Native for desktop clients)
  3. Set the redirect URI to your MCP client's callback — for Claude desktop: http://localhost:PORT/callback; for web-based clients: your actual callback URL
  4. Enable Proof Key for Code Exchange (PKCE) — this is non-negotiable for desktop clients
  5. Set Grant type: Authorization Code only
  6. Note the Client ID — this gets distributed to MCP clients (it's public, not a secret in PKCE flow)

Step 2 — Define Custom OAuth Scopes

Okta Authorization Server → Scopes → Add Scope:

Name: mcp:tools:read
Description: Read-only access to MCP tools
Default scope: No
Metadata: Include in public metadata

Name: mcp:tools:execute  
Description: Execute MCP tools
Default scope: No

Name: mcp:tools:admin
Description: Administrative access to MCP configuration
Default scope: No
Consent: Required (forces explicit user acknowledgment)

Step 3 — Create Group-Based Claims

This is where governance happens. Add a custom claim that maps Okta groups to the JWT:

Okta Authorization Server → Claims → Add Claim:

Name: groups
Include in: Access Token
Value type: Groups
Filter: Matches regex — ^mcp-.*
Include in: Any scope

This adds group membership to every access token. Your MCP server can then read token.groups to determine authorization.

Step 4 — Create Access Policies

Okta Authorization Server → Access Policies → Add Policy:

Policy name: MCP Tool Access
Assign to: Groups — mcp-users (or Everyone)

Rule:
  Rule name: Standard MCP Access
  If user is: In group mcp-users
  Scopes requested: mcp:tools:read, mcp:tools:execute
  Grant: Access Token valid for 1 hour
  Refresh token: Allowed, valid for 24 hours
  
Rule:
  Rule name: Admin MCP Access  
  If user is: In group mcp-admins
  Scopes requested: mcp:tools:admin
  Grant: Access Token valid for 1 hour

Step 5 — Configure Your MCP Server to Validate Tokens

Never validate JWTs manually. Use a well-maintained library:

typescript
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { Request, Response, NextFunction } from 'express';

const JWKS = createRemoteJWKSet(
  new URL(`https://${process.env.OKTA_DOMAIN}/oauth2/v1/keys`)
);

const EXPECTED_ISSUER = `https://${process.env.OKTA_DOMAIN}`;
const EXPECTED_AUDIENCE = process.env.MCP_CLIENT_ID; // Okta client ID

export interface AuthenticatedRequest extends Request {
  user?: {
    sub: string;
    email: string;
    groups: string[];
    scope: string;
  };
}

export async function authenticateToken(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'missing_token' });
    return;
  }
  
  const token = authHeader.slice(7);
  
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: EXPECTED_ISSUER,
      audience: EXPECTED_AUDIENCE,
    });
    
    // Validate token hasn't been revoked (check against Okta introspection endpoint
    // for high-security environments — adds latency but catches revoked tokens)
    
    req.user = {
      sub: payload.sub as string,
      email: payload.email as string,
      groups: (payload.groups as string[]) || [],
      scope: payload.scp as string || payload.scope as string || '',
    };
    
    next();
  } catch (err) {
    // Log the error type for debugging, but never expose JWT internals to clients
    console.error('Token validation failed:', (err as Error).message);
    res.status(401).json({ error: 'invalid_token' });
  }
}

Why jose over jsonwebtoken? The jose library fetches and caches the JWKS endpoint automatically, handles key rotation, and is maintained for modern runtimes. jsonwebtoken requires you to manage public key fetching yourself and has had historical issues with algorithm confusion attacks.


Role-Based Access Control at the Tool Level

Token validation is the gate. RBAC is the policy inside the gate. After authenticating, your MCP server needs to enforce which tools each user or group can actually call.

typescript
type ToolPermission = {
  requiredScope: string;
  requiredGroups?: string[]; // any of these groups grants access
  rateLimit?: number; // calls per minute
};

const TOOL_PERMISSIONS: Record<string, ToolPermission> = {
  'list_databases': {
    requiredScope: 'mcp:tools:read',
  },
  'execute_query': {
    requiredScope: 'mcp:tools:execute',
    requiredGroups: ['mcp-data-analysts', 'mcp-engineers'],
    rateLimit: 60,
  },
  'drop_table': {
    requiredScope: 'mcp:tools:admin',
    requiredGroups: ['mcp-admins'],
    rateLimit: 5,
  },
  'export_all_data': {
    requiredScope: 'mcp:tools:execute',
    requiredGroups: ['mcp-data-scientists'],
    rateLimit: 10,
  },
};

export function authorizeToolCall(
  toolName: string,
  user: AuthenticatedRequest['user']
): { allowed: boolean; reason?: string } {
  const permission = TOOL_PERMISSIONS[toolName];
  
  if (!permission) {
    return { allowed: false, reason: 'tool_not_found' };
  }
  
  // Check scope
  const userScopes = (user?.scope || '').split(' ');
  if (!userScopes.includes(permission.requiredScope)) {
    return { 
      allowed: false, 
      reason: `missing_scope:${permission.requiredScope}` 
    };
  }
  
  // Check group membership (if required)
  if (permission.requiredGroups && permission.requiredGroups.length > 0) {
    const userGroups = user?.groups || [];
    const hasRequiredGroup = permission.requiredGroups.some(
      group => userGroups.includes(group)
    );
    
    if (!hasRequiredGroup) {
      return {
        allowed: false,
        reason: `unauthorized_group:requires_one_of:${permission.requiredGroups.join(',')}`
      };
    }
  }
  
  return { allowed: true };
}

Apply this in your MCP tool handler:

typescript
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';

server.setRequestHandler(CallToolRequestSchema, async (request, context) => {
  const user = (context as any).user; // injected by auth middleware
  const toolName = request.params.name;
  
  const { allowed, reason } = authorizeToolCall(toolName, user);
  
  // Audit log regardless of outcome
  auditLog({
    event: allowed ? 'tool_call' : 'tool_call_denied',
    user_id: user?.sub,
    user_email: user?.email,
    user_groups: user?.groups,
    tool: toolName,
    reason: reason,
    timestamp: new Date().toISOString(),
  });
  
  if (!allowed) {
    return {
      content: [{ type: 'text', text: `Access denied: ${reason}` }],
      isError: true,
    };
  }
  
  // Execute the tool...
});

Audit Logging That Actually Satisfies Compliance

Compliance teams don't want logs. They want attributable, structured, tamper-evident logs. Here's the difference between audit theater and real audit logging.

typescript
import { createLogger, transports, format } from 'winston';

// Structured JSON logs — essential for SIEM ingestion
const auditLogger = createLogger({
  level: 'info',
  format: format.combine(
    format.timestamp(),
    format.json()
  ),
  transports: [
    // Ship to your log aggregator (Splunk, Datadog, CloudWatch)
    new transports.Console(),
    // Or a transport that ships directly to your SIEM
  ],
  defaultMeta: {
    service: 'mcp-server',
    environment: process.env.NODE_ENV,
    server_id: process.env.MCP_SERVER_ID,
  }
});

interface AuditEvent {
  event: 'tool_call' | 'tool_call_denied' | 'auth_success' | 'auth_failure' | 'token_revoked';
  user_id: string | undefined;
  user_email: string | undefined;
  user_groups: string[] | undefined;
  tool: string;
  input_summary?: string; // sanitized, not raw input
  response_status?: 'success' | 'error';
  reason?: string;
  session_id?: string;
  ip_address?: string;
  timestamp: string;
  duration_ms?: number;
}

export function auditLog(event: AuditEvent): void {
  auditLogger.info('mcp_audit_event', event);
}

// Sanitize tool inputs before logging
// Some tools receive sensitive data — never log raw SQL queries, passwords, PII
const SAFE_TO_LOG_PARAMS: Record<string, string[]> = {
  'execute_query': ['database', 'limit'], // log these, not the query itself
  'list_databases': [], // no params
  'export_data': ['table_name', 'format'], // not filters that might contain PII
};

export function sanitizeInputForAudit(
  toolName: string,
  rawInput: Record<string, unknown>
): string {
  const safeFields = SAFE_TO_LOG_PARAMS[toolName] || [];
  const sanitized: Record<string, unknown> = {};
  
  for (const field of safeFields) {
    if (field in rawInput) {
      sanitized[field] = rawInput[field];
    }
  }
  
  return JSON.stringify(sanitized);
}

A proper audit event in your SIEM should answer:

  • Who called the tool (user_id, email, groups)
  • What they called (tool name, sanitized input summary)
  • When (ISO timestamp with milliseconds)
  • From where (IP address, session ID)
  • What happened (success/denied, reason, duration)

This is what lets a security team reconstruct the full timeline of a data breach or policy violation.


Microsoft Entra ID (Azure AD) Integration

For Microsoft-heavy enterprises, Entra ID replaces Okta in the architecture. The discovery document structure is identical — only the endpoints change.

typescript
// Entra ID discovery document endpoints
const TENANT_ID = process.env.AZURE_TENANT_ID;

const ENTRA_METADATA = {
  authorization_endpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize`,
  token_endpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`,
  jwks_uri: `https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`,
  issuer: `https://login.microsoftonline.com/${TENANT_ID}/v2.0`,
};

Entra ID-specific gotchas:

  1. Group claims require configuration. By default, Entra ID doesn't include group membership in access tokens. You must configure it in the app manifest: "groupMembershipClaims": "SecurityGroup". For users in more than 200 groups, Entra ID emits a hasGroups: true claim instead and you must call the Microsoft Graph API separately.

  2. Audience for Entra ID tokens is the Application ID URI, not the client ID. Set your server's expected audience accordingly.

  3. Multi-tenant apps need to validate iss carefully — the issuer format changes between single-tenant and multi-tenant tokens.

typescript
// Entra ID token validation
const ENTRA_JWKS = createRemoteJWKSet(
  new URL(`https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/discovery/v2.0/keys`)
);

const { payload } = await jwtVerify(token, ENTRA_JWKS, {
  issuer: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0`,
  audience: process.env.AZURE_APP_ID_URI, // e.g. api://mcp-server-prod
});

// Entra ID puts groups in 'groups' claim as object IDs, not names
// You'll need to map object IDs to group names if using display names for RBAC
const groupObjectIds = payload.groups as string[] || [];

Claude Desktop and VS Code: Client Configuration

Claude Desktop

Claude's MCP integration reads server configuration from ~/Library/Application Support/Claude/claude_desktop_config.json (macOS). For EMA-enabled servers, no token configuration is needed — the client initiates the OAuth flow automatically when it reads the server's discovery metadata.

json
{
  "mcpServers": {
    "enterprise-data": {
      "url": "https://mcp.internal.yourcompany.com",
      "transport": "http",
      "auth": {
        "type": "oauth2",
        "clientId": "0oa5xyz...",
        "scopes": ["openid", "profile", "mcp:tools:execute"]
      }
    }
  }
}

The clientId here is your Okta application's public Client ID. Because PKCE is used, there is no client secret in this configuration. Anyone who reads this file gets the Client ID — which is harmless because they still need to authenticate as a corporate user with MFA to obtain tokens.

VS Code MCP Extension

VS Code's MCP support (available in VS Code 1.99+) follows the same discovery model. Add to your workspace's .vscode/mcp.json:

json
{
  "servers": {
    "enterprise-data": {
      "type": "http",
      "url": "https://mcp.internal.yourcompany.com/mcp",
      "gallery": false
    }
  }
}

VS Code will call /.well-known/oauth-authorization-server on that URL, read the authorization endpoint, and open a browser window for SSO when the user first connects. Subsequent calls use the cached token until expiry, then trigger a silent refresh.

Pro tip for VS Code deployments: Set refresh token lifetime in Okta to match your corporate session policy (typically 8–24 hours). This prevents users from being interrupted by re-authentication prompts during a working session.


Zero-Touch OAuth: What It Means in Practice

"Zero-touch" describes the employee experience, not the admin experience. Here's what it looks like from both perspectives:

Employee perspective:

  1. Opens Claude or VS Code
  2. Connects to an MCP server
  3. A browser window opens showing their company's SSO page (which they may be already logged into)
  4. They click "Allow" on the consent screen (or it's pre-approved for low-risk scopes)
  5. The browser closes, the client has a token, and they start using tools

Total manual steps: 1–2. No token copying. No config files to edit. No IT tickets.

Admin perspective:

  1. Create Okta app and scopes (one-time setup)
  2. Create Okta groups and assign users (ongoing, but this is normal directory management)
  3. Deploy MCP server with discovery endpoint (one-time)
  4. Add mcp.json to VS Code workspace or distribute Claude config (one-time per deployment)

The ongoing governance burden falls to the IdP, which your team already manages. Adding or removing a user's MCP tool access is just adding or removing them from an Okta group — the same workflow as any SaaS application.


Enterprise Architecture: Production Deployment

A production EMA deployment at enterprise scale typically looks like this:

┌─────────────────────────────────────────────────────────────┐
│                    Enterprise MCP Architecture               │
└─────────────────────────────────────────────────────────────┘

  External / Internal Clients
  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐
  │ Claude Desktop│  │  VS Code     │  │  Custom MCP Client   │
  └──────┬───────┘  └──────┬───────┘  └──────────┬───────────┘
         │                 │                       │
         └────────────────┬┘                       │
                          ▼                        │
  ┌────────────────────────────────────────────────┘
  │
  ▼
┌─────────────────────────────────────────────────────────────┐
│              API Gateway / Load Balancer                     │
│     (TLS termination, rate limiting, IP allowlisting)       │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│              MCP Server Cluster (Auto-scaled)               │
│                                                             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ │
│  │ MCP Server 1│  │ MCP Server 2│  │   MCP Server N      │ │
│  │             │  │             │  │                     │ │
│  │ ┌─────────┐ │  │ ┌─────────┐ │  │ ┌─────────────────┐ │ │
│  │ │Auth MW  │ │  │ │Auth MW  │ │  │ │   Auth MW       │ │ │
│  │ │(JWT     │ │  │ │(JWT     │ │  │ │   (JWT Verify)  │ │ │
│  │ │Verify)  │ │  │ │Verify)  │ │  │ └─────────────────┘ │ │
│  │ └─────────┘ │  │ └─────────┘ │  │                     │ │
│  │ ┌─────────┐ │  │ ┌─────────┐ │  │ ┌─────────────────┐ │ │
│  │ │RBAC     │ │  │ │RBAC     │ │  │ │   RBAC Engine   │ │ │
│  │ │Engine   │ │  │ │Engine   │ │  │ └─────────────────┘ │ │
│  │ └─────────┘ │  │ └─────────┘ │  │                     │ │
│  │ ┌─────────┐ │  │ ┌─────────┐ │  │ ┌─────────────────┐ │ │
│  │ │Audit    │ │  │ │Audit    │ │  │ │   Audit Logger  │ │ │
│  │ │Logger   │ │  │ │Logger   │ │  │ └─────────────────┘ │ │
│  │ └────┬────┘ │  │ └────┬────┘ │  │                     │ │
│  └──────│──────┘  └──────│──────┘  └─────────────────────┘ │
└─────────│────────────────│─────────────────────────────────┘
          │                │
          ▼                ▼
┌───────────────────────────────────┐
│      Log Aggregator               │
│  (Fluentd / Logstash / Firehose)  │
└───────────────────┬───────────────┘
                    │
          ┌─────────┴───────────┐
          ▼                     ▼
   ┌────────────┐        ┌────────────────┐
   │  SIEM      │        │  Observability │
   │  (Splunk/  │        │  (Datadog/     │
   │  Sentinel) │        │  Grafana)      │
   └────────────┘        └────────────────┘
          │
          ▼
   ┌────────────────────────────┐
   │  Enterprise IdP (Okta)     │
   │  - Token issuance          │
   │  - JWKS endpoint           │
   │  - Group management        │
   │  - MFA enforcement         │
   │  - Session policies        │
   └────────────────────────────┘

Key Production Decisions

JWKS caching: Fetching the JWKS on every request adds 50–200ms latency. The jose library caches the JWKS automatically. Set a cache TTL of 5 minutes — short enough to pick up key rotations, long enough to avoid hammering Okta's endpoint. For extremely high-throughput servers, consider a local Redis cache with the JWKS document.

Token introspection vs. local validation: Local JWT validation (verify signature + claims) is fast but doesn't detect revoked tokens until expiry. Token introspection (calling Okta's /introspect endpoint) detects revocation immediately but adds 100–300ms per request. For most MCP tool calls, local validation with short token TTLs (1 hour) is an acceptable trade-off. For tools that execute destructive operations, consider introspection.

Stateless vs. session tokens: Keep MCP servers stateless. Don't store tokens server-side. The JWT carries all the information you need. Statelessness makes horizontal scaling trivial.


Common Security Mistakes and How to Avoid Them

Mistake 1: Not Validating the aud Claim

What happens: You validate iss and exp but not aud. Any valid JWT from your Okta instance — issued for Salesforce, Slack, any internal app — will pass your token validation.

Why it matters: An attacker with a token from a low-security application can use it to call your MCP tools.

Fix: Always validate audience equals your MCP application's Client ID or Application ID URI:

typescript
// Wrong — no audience validation
await jwtVerify(token, JWKS, { issuer: EXPECTED_ISSUER });

// Correct
await jwtVerify(token, JWKS, { 
  issuer: EXPECTED_ISSUER,
  audience: EXPECTED_AUDIENCE  // Your specific MCP app's client ID
});

Mistake 2: Logging Raw Tool Inputs

What happens: You log everything for debugging. Tool inputs contain SQL queries with WHERE clauses containing PII, file paths exposing directory structure, or request bodies with sensitive business data.

Why it matters: Your audit log becomes a secondary data breach vector. GDPR, HIPAA, and SOC2 auditors will flag this.

Fix: Define a per-tool allowlist of safe-to-log fields. Log tool name, user identity, and outcome. Log sanitized or hashed inputs.

Mistake 3: Exposing groups Claim Without Filtering

What happens: You configure Okta to include all group memberships in the JWT. A user in 50 groups gets a JWT with all 50 groups, some of which reveal internal organizational structure.

Fix: Use Okta's group filter to only include groups matching an mcp- prefix (or whatever namespace you use for MCP). Users' other group memberships stay private.

Mistake 4: Not Rotating the Client Secret (When Used)

What happens: For confidential clients (server-to-server), the client secret is set once and forgotten for years.

Fix: For server-to-server flows (service accounts), use mTLS or private key JWT authentication instead of client secrets. If you must use secrets, rotate them quarterly and store them in your secrets manager (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault).

Mistake 5: Trusting the groups Claim Without Understanding Source-of-Truth

What happens: A user is removed from an Okta group (because they left a team), but they have a valid cached token for another 55 minutes. During that window, they still have access to tools they shouldn't.

Fix: For sensitive tools, use token introspection or implement a short access token TTL (15–30 minutes) with refresh token rotation. For extremely sensitive operations, require fresh tokens (max_age=0 in the authorization request).


Security Validation with MCPForge

Before deploying an EMA-enabled MCP server to production, run it through MCPForge Verify. The Verify tool analyzes your server's security configuration, including:

  • Whether your discovery document correctly exposes the enterprise IdP endpoints
  • Whether your token validation rejects tokens with missing or incorrect aud claims
  • Whether your tools return appropriate 401/403 responses to unauthenticated or unauthorized requests
  • Whether sensitive information leaks through error messages

For teams managing multiple MCP servers across business units, the MCPForge Security Reports provide a consolidated view of authorization configuration issues across your fleet. This matters in enterprise environments where different teams may deploy MCP servers independently — centralized security review catches the same class of misconfiguration appearing repeatedly.

For broader guidance on hardening your MCP deployment beyond authorization, see MCP Security Best Practices.


Governance Policies: What to Define Before You Deploy

EMA gives you the infrastructure for governance. But the policies themselves are organizational decisions that need to happen before the first line of code. Here's a practical governance framework:

Access Tiers

Define three or four access tiers and map them to scopes:

TierScopeWho Gets ItExample Tools
Readmcp:tools:readAll employeeslist databases, search documentation
Executemcp:tools:executeTeam members, engineersquery data, run analysis, file operations
Writemcp:tools:writeSenior engineers, approved userscreate records, modify configs
Adminmcp:tools:adminSRE, security team onlydrop tables, manage server config, bulk operations

Review Cadence

  • Quarterly: Review who is in which MCP group. Remove departed employees (should happen via offboarding automation, but verify).
  • Monthly: Review audit logs for unusual patterns — after-hours access, unusual data volumes, repeated denied requests.
  • Per-incident: When an employee leaves a team, revoke group membership immediately. Okta's User Deprovisioning can automate this.

Tool Onboarding Policy

Every new tool added to a production MCP server should go through a review that answers:

  1. What data does this tool access or modify?
  2. What is the minimum required scope?
  3. Which groups should have access?
  4. What should be logged?
  5. Are there rate limits needed?
  6. What does authorization failure look like to the user?

Testing Your EMA Implementation

Don't just test the happy path. Here's a test matrix for EMA:

typescript
// Test suite for MCP authorization middleware
import { describe, test, expect } from 'vitest';
import { generateKeyPair, SignJWT, exportJWK } from 'jose';

describe('MCP Authorization Middleware', () => {
  
  // Generate test keys — never use in production
  let privateKey: CryptoKey;
  let publicKey: CryptoKey;
  
  beforeAll(async () => {
    const { privateKey: priv, publicKey: pub } = await generateKeyPair('RS256');
    privateKey = priv;
    publicKey = pub;
  });
  
  async function signToken(claims: Record<string, unknown>): Promise<string> {
    return new SignJWT(claims)
      .setProtectedHeader({ alg: 'RS256' })
      .setIssuedAt()
      .setIssuer('https://yourcompany.okta.com')
      .setAudience('mcp-client-id')
      .setExpirationTime('1h')
      .sign(privateKey);
  }
  
  test('rejects requests with no token', async () => {
    const response = await callToolWithoutAuth('list_databases');
    expect(response.status).toBe(401);
  });
  
  test('rejects tokens with wrong audience', async () => {
    const token = await signToken({ 
      sub: 'user@company.com',
      aud: 'salesforce-app-id', // wrong audience
      scope: 'mcp:tools:execute'
    });
    const response = await callToolWithToken('list_databases', token);
    expect(response.status).toBe(401);
  });
  
  test('rejects expired tokens', async () => {
    const expiredToken = await new SignJWT({ sub: 'user@company.com' })
      .setProtectedHeader({ alg: 'RS256' })
      .setIssuedAt(Math.floor(Date.now() / 1000) - 7200)
      .setExpirationTime(Math.floor(Date.now() / 1000) - 3600) // expired 1h ago
      .setIssuer('https://yourcompany.okta.com')
      .setAudience('mcp-client-id')
      .sign(privateKey);
    
    const response = await callToolWithToken('list_databases', expiredToken);
    expect(response.status).toBe(401);
  });
  
  test('denies tool access without required group', async () => {
    const token = await signToken({
      sub: 'user@company.com',
      email: 'user@company.com',
      groups: ['mcp-users'], // not in mcp-data-analysts
      scope: 'mcp:tools:execute',
    });
    const response = await callToolWithToken('execute_query', token);
    expect(response.status).toBe(403);
  });
  
  test('allows tool access with correct group and scope', async () => {
    const token = await signToken({
      sub: 'analyst@company.com',
      email: 'analyst@company.com',
      groups: ['mcp-users', 'mcp-data-analysts'],
      scope: 'mcp:tools:execute',
    });
    const response = await callToolWithToken('execute_query', token);
    expect(response.status).toBe(200);
  });
  
  test('emits audit log on tool call denial', async () => {
    const auditSpy = vi.spyOn(auditLogger, 'info');
    const token = await signToken({
      sub: 'user@company.com',
      groups: ['mcp-users'],
      scope: 'mcp:tools:read',
    });
    await callToolWithToken('drop_table', token);
    
    expect(auditSpy).toHaveBeenCalledWith(
      'mcp_audit_event',
      expect.objectContaining({
        event: 'tool_call_denied',
        tool: 'drop_table',
      })
    );
  });
});

Run this suite against a local MCP server instance in CI, and add a smoke test against staging that verifies the real Okta integration works end-to-end before every production deployment.


Recent Changes in MCP Authorization

The MCP specification has been actively evolving on the authorization front:

OAuth 2.1 alignment (early 2025): The MCP specification has moved toward OAuth 2.1 semantics, which mandates PKCE for all authorization code flows (not just public clients) and deprecates the implicit grant type. If you implemented MCP OAuth before mid-2024, verify you're using Authorization Code + PKCE everywhere.

Dynamic Client Registration (RFC 7591) support: Newer MCP clients support dynamic client registration, allowing clients to register themselves with the authorization server at runtime rather than requiring pre-registered Client IDs. This simplifies deployment for large client fleets but requires your IdP to support RFC 7591. Okta supports this via its API Access Management product.

Authorization metadata in server capabilities: Recent Claude and VS Code updates read OAuth metadata from the MCP server's capabilities response as an alternative to the /.well-known discovery endpoint. Both mechanisms should be supported for maximum compatibility.


Production Readiness Checklist

Before declaring your EMA deployment production-ready:

Identity & Token Configuration

  • Okta/Entra ID application created with PKCE enabled
  • Custom OAuth scopes defined and documented
  • Group-to-scope mapping configured in IdP access policies
  • MFA enforced for all users with mcp:tools:execute or higher
  • Access token TTL set to ≤ 1 hour
  • Refresh token rotation enabled

Server Configuration

  • Discovery endpoint (/.well-known/oauth-authorization-server) returns correct IdP URLs
  • JWT validation checks iss, aud, exp, and scope
  • JWKS caching configured (5-minute TTL)
  • All tool handlers enforce RBAC before execution
  • Error responses never expose JWT internals or stack traces

Audit & Observability

  • Structured JSON audit logs emitted on every tool call and denial
  • Logs shipped to SIEM or log aggregator
  • Alerting configured for repeated auth failures (brute-force detection)
  • Dashboard showing per-user, per-tool call volume
  • Retention policy set per compliance requirements (SOC2: 1 year, HIPAA: 6 years)

Network & Deployment

  • MCP server only accessible via HTTPS (TLS 1.2+)
  • Internal servers behind VPN or IP allowlist (defense in depth)
  • Rate limiting configured at API gateway level
  • Health check endpoint that does NOT require authentication
  • Secrets (none ideally, or stored in secrets manager) rotated on schedule

Operational

  • Runbook for revoking a compromised user's MCP access (remove from IdP group)
  • Process for offboarding employees from MCP groups
  • Quarterly access review scheduled
  • Incident response plan covers MCP tool abuse scenarios

Key Takeaways

Enterprise-Managed Authorization solves the governance problems that make standard MCP OAuth unsuitable for production enterprise deployments. The architecture is straightforward: your MCP server becomes a resource server, your corporate IdP becomes the authorization server, and the MCP client handles the OAuth flow transparently for users.

The most important implementation decisions are:

  1. Validate iss, aud, exp, and scope on every request — not just signature validity
  2. Map IdP groups to tool permissions explicitly — don't grant broad access by default
  3. Log every tool call and every denial with full user attribution — not for debugging, for compliance
  4. Keep tokens short-lived — 1-hour access tokens limit the blast radius of a compromised token
  5. Test authorization failures as thoroughly as successes — your RBAC logic is a security boundary

The zero-touch experience for employees is achievable without compromising security. When the authorization flow is invisible to users and powerful for administrators, adoption follows naturally.

Frequently Asked Questions

What is Enterprise-Managed Authorization for MCP?

Enterprise-Managed Authorization (EMA) is an MCP protocol feature that delegates OAuth authorization to a corporate identity provider (IdP) like Okta, Azure AD, or Google Workspace. Instead of individual users managing tokens or clients presenting their own credentials, the enterprise centrally controls which users, groups, and applications can access specific MCP tools — with full audit logging and policy enforcement.

Does Enterprise-Managed Authorization work with Claude and Cursor?

Claude.ai and Claude desktop support EMA when the MCP server advertises the enterprise authorization endpoint in its discovery metadata. VS Code's MCP extension also supports the flow. The client initiates a standard OAuth 2.0 authorization code flow with PKCE, but the authorization server is your corporate IdP rather than a server-specific OAuth app.

What identity providers are supported with MCP Enterprise-Managed Authorization?

Any OAuth 2.0 / OIDC-compliant identity provider works, including Okta, Microsoft Entra ID (Azure AD), Google Workspace, Ping Identity, Auth0, and Keycloak. The MCP spec does not mandate a specific IdP — it defines the discovery and token exchange protocol, and you configure your IdP as the authorization server.

How does zero-touch OAuth differ from standard OAuth in MCP?

Standard OAuth in MCP requires each server to run its own authorization server or hard-code client credentials. Zero-touch OAuth (the EMA model) means the MCP client discovers the enterprise authorization endpoint automatically via server metadata, the enterprise IdP handles consent and token issuance, and users never manually configure tokens. The client just opens a browser, the user authenticates with their corporate SSO, and the token flows back automatically.

Can I scope MCP tool access by Active Directory group or Okta group?

Yes. You map IdP groups to OAuth scopes or use claims-based authorization in your MCP server middleware. For example, members of the 'data-science' Okta group receive the 'tools:execute:ml-pipeline' scope, while members of 'finance' receive 'tools:read:reporting'. Your MCP server validates the JWT claims on every tool call and enforces group-based access accordingly.

Is EMA the same as OAuth Dynamic Client Registration in MCP?

No. Dynamic Client Registration (RFC 7591) allows MCP clients to register themselves with an authorization server at runtime. EMA is a higher-level pattern that may use dynamic registration as one mechanism, but its defining characteristic is that the authorization server is enterprise-controlled and policy-driven. EMA can work with pre-registered clients, dynamically registered clients, or both.

What does an MCP server need to expose for Enterprise-Managed Authorization to work?

The server must expose a discovery document at /.well-known/oauth-authorization-server or /.well-known/mcp (depending on the client implementation) that includes the authorization_endpoint, token_endpoint, and supported scopes pointing to the enterprise IdP. The server itself becomes a resource server — it validates tokens but delegates issuance to the IdP.

How do I audit which MCP tools users are calling in an enterprise deployment?

Implement structured audit logging in your MCP server middleware that captures the JWT sub (user identity), the tool name, input parameters (sanitized), timestamp, and response status. Ship these logs to your SIEM (Splunk, Datadog, etc.) via a log aggregator. The JWT from the enterprise IdP gives you the user's email and group memberships, making logs attributable to real employees.

What is the biggest security mistake teams make when deploying MCP with OAuth?

Accepting tokens without validating the audience (aud) claim. If your MCP server accepts any valid JWT from your IdP without checking that the token was specifically issued for that MCP server's client ID, an attacker who obtains a token for a different application can replay it against your MCP tools. Always validate iss, aud, exp, and scope on every request.

Should MCP tool parameters be included in audit logs?

Include them, but with care. Tool inputs can contain sensitive data (SQL queries, file paths, PII). Log the tool name and a sanitized or hashed version of inputs by default, and configure allowlists of safe-to-log parameter fields per tool. This gives you enough audit detail for security investigations without creating a secondary data exposure risk in your logging infrastructure.

Check your MCP security posture

Generate a Security Score, detect risky tools, and review permissions before exposing APIs to AI agents.