Security & Access Control · 2026-07-09 · Security & Access Control arc

MCP Tools for Security Platforms: Principal Validation, Short-Lived Credentials, and Health Probes Across AWS IAM, Okta, Auth0, OPA, and Cloudflare Access

Security platform integrations are not like SaaS API integrations. The failure modes are more severe — a misconfigured credential refresh silently fails every downstream tool call; a policy evaluation shortcut causes authorization bypasses; a process-up health check masks the exact failure the monitoring was supposed to catch. We integrated five major security platforms as MCP tools — AWS IAM, Okta, Auth0, Open Policy Agent, and Cloudflare Access — and three patterns emerged that every one of them requires. This is the synthesis.

Three patterns, five platforms

The five platforms covered here span the full access control stack: AWS IAM is the permission system for cloud infrastructure — it says what a principal can do. Okta and Auth0 are identity providers — they say who a principal is. Open Policy Agent is a policy engine — it evaluates arbitrary policy logic against any input. Cloudflare Access is a zero-trust gateway — it enforces identity at the network layer before a request reaches your application. Despite covering completely different layers of the security stack, all five share the same three failure patterns:

  1. The credential lifecycle pattern — every platform uses a different credential type with a different expiry model and a different revocation signal. Getting this wrong means silent failures when credentials expire.
  2. The policy evaluation pattern — every platform has a "what does this rule say" endpoint and a "what is this principal's effective permission" endpoint. They are not the same endpoint. Using the wrong one causes authorization bypasses or incorrect audit results.
  3. The health probe pattern — every platform has a specific failure mode that only the right probe detects. Generic process health checks pass when every tool call is failing.

Pattern 1: The credential lifecycle

Every security platform uses a different credential primitive with a different expiry mechanism and a different revocation signal. The table below shows how they diverge:

Platform Credential type Expiry model Revocation signal Cache strategy
AWS IAM / STS Temporary credentials (AssumeRole) TTL in Credentials.Expiration field (15min–12hr) ExpiredTokenException or InvalidClientTokenId Cache until Expiration − 60s
Okta SSWS API token No automatic expiry; tied to admin user session HTTP 401 with errorCode: E0000011 Store in env var; no caching needed — long-lived
Auth0 M2M JWT (client_credentials) TTL in expires_in field (default 86,400s / 24hr) HTTP 401 with {"statusCode":401,"error":"Unauthorized"} Cache until Date.now() + expires_in*1000 − 60,000
Open Policy Agent Bearer token (optional) No credential expiry — but bundles can go stale N/A for token; stale bundle via /health?bundles=true Store in env var; monitor bundle freshness separately
Cloudflare Access API token (scoped) Optional expiry in expires_on field; JWTs ~1hr token_status: expired from /user/tokens/verify Store in env var; check status via verify endpoint

AWS IAM: cache STS assumed-role credentials until near-expiry

The AWS SDK v3 fromNodeProviderChain() handles long-lived credential refresh automatically (instance metadata roles refresh continuously). But when your MCP tool calls sts:AssumeRole to adopt a cross-account identity, the returned temporary credentials are opaque to the SDK — your code must manage them. The canonical approach is a per-role cache that renews 60 seconds before Expiration:

const roleCredentialCache = new Map<string, {
  accessKeyId: string;
  secretAccessKey: string;
  sessionToken: string;
  expiresAt: Date;
}>();

async function getAssumedRoleCredentials(roleArn: string, sessionName: string) {
  const cached = roleCredentialCache.get(roleArn);
  if (cached && cached.expiresAt.getTime() - Date.now() > 60_000) {
    return cached;
  }

  const res = await stsClient.send(new AssumeRoleCommand({
    RoleArn: roleArn,
    RoleSessionName: sessionName,
    DurationSeconds: 3600
  }));

  const creds = {
    accessKeyId: res.Credentials!.AccessKeyId!,
    secretAccessKey: res.Credentials!.SecretAccessKey!,
    sessionToken: res.Credentials!.SessionToken!,
    expiresAt: res.Credentials!.Expiration!
  };

  roleCredentialCache.set(roleArn, creds);
  return creds;
}

The 60-second buffer is not arbitrary. AWS SDK calls can take up to 20 seconds in high-latency environments; a credential expiring during an in-flight API call causes ExpiredTokenException mid-request, which is harder to retry than a pre-expiry refresh. The default session duration is 3,600 seconds (1 hour), but roles can be configured for up to 43,200 seconds (12 hours). If you're chaining role assumptions — Role A assumes Role B — the chained session duration is capped at Role A's remaining lifetime, not Role B's maximum.

Auth0: fetch M2M tokens with automatic refresh

Auth0 Management API tokens are short-lived JWTs from the client_credentials grant. The token endpoint has a separate rate limit of 30 requests per minute — caching is mandatory, not optional. A naive implementation that fetches a new token on every tool call will exhaust this limit under any concurrent load:

interface M2MToken {
  accessToken: string;
  expiresAt: number;
}

let cachedToken: M2MToken | null = null;

async function getM2MToken(): Promise<string> {
  if (cachedToken && cachedToken.expiresAt - Date.now() > 60_000) {
    return cachedToken.accessToken;
  }

  const res = await axios.post(`https://${AUTH0_DOMAIN}/oauth/token`, {
    grant_type: 'client_credentials',
    client_id: AUTH0_CLIENT_ID,
    client_secret: AUTH0_CLIENT_SECRET,
    audience: `https://${AUTH0_DOMAIN}/api/v2/`
  });

  cachedToken = {
    accessToken: res.data.access_token,
    expiresAt: Date.now() + res.data.expires_in * 1000
  };

  return cachedToken.accessToken;
}

The default TTL for Auth0 Management API tokens is 86,400 seconds (24 hours), but tenants can configure shorter values. Never hardcode an Auth0 Management API token — tenant rotation can change the token at any time. Always use client_credentials and cache the result.

Okta: long-lived SSWS tokens with instant revocation

Okta's SSWS (Simple Web Service Secure) API tokens are long-lived — they don't expire on a schedule. This sounds convenient, but the failure mode is the inverse: a revoked token fails immediately and permanently. SSWS tokens are tied to a specific admin user's permissions; if that user is deactivated or their permissions change, every API call using their token fails with errorCode: E0000011.

The correct handling for an Okta E0000011 error is to surface the specific message to the MCP caller and fail fast — there is no automatic retry path (unlike a transient 429 rate limit). The monitoring implication is that Okta token health must be checked proactively, not reactively, since the token itself gives no signal until it's already broken:

// Okta error response shape for a revoked token
// HTTP 401
// {
//   "errorCode": "E0000011",
//   "errorSummary": "Invalid token provided",
//   "errorLink": "E0000011",
//   "errorId": "oaeXyz...",
//   "errorCauses": []
// }

oktaHttp.interceptors.response.use(undefined, (error) => {
  if (error.response?.status === 401) {
    const code = error.response.data?.errorCode;
    if (code === 'E0000011') {
      throw new McpError(ErrorCode.InvalidRequest,
        'Okta API token has been revoked. Rotate the OKTA_API_TOKEN environment variable.');
    }
  }
  return Promise.reject(error);
});

Cloudflare: scoped API tokens with optional expiry dates

Cloudflare API tokens are the newest credential model in this group. They're created with scoped permissions (not global access), and they can optionally have an expires_on date set at creation time. Unlike the other platforms, the expiry date is a first-class field you can read via the verify endpoint — which means your health check can proactively report days-until-expiry, not just binary valid/invalid:

// GET /user/tokens/verify response
// {
//   "result": {
//     "id": "abc123",
//     "status": "active",  // 'active' | 'disabled' | 'expired'
//     "not_before": "2026-01-01T00:00:00Z",
//     "expires_on": "2027-01-01T00:00:00Z"   // present only if expiry was set
//   },
//   "success": true
// }

The revoke_user operation in Cloudflare Access (POST /access/organizations/:org_id/revoke_user) invalidates all JWTs for a user across all Access applications immediately — this is the fastest access revocation mechanism of the five platforms in this guide. The revocation propagates to all Cloudflare edge nodes within seconds, without any token TTL window.

Pattern 2: The policy evaluation gap

Every security platform has two different questions you can ask about permissions:

  1. What does this rule say? (Read the policy document)
  2. What can this principal actually do? (Evaluate effective permissions)

These questions have different answers whenever additional layers (organization policies, permission boundaries, role bindings, session policies, group membership) exist between the raw policy document and the effective authorization decision. The most common mistake in security platform MCP integrations is answering question 2 with the API for question 1.

AWS IAM: SimulatePrincipalPolicy, not ListPolicies

The most dangerous shortcut in IAM integrations is reading policy documents directly and trying to determine what a principal can do from the JSON. A principal's effective permissions are the intersection of all applicable policy layers: identity-based policies, resource-based policies, permission boundaries, Service Control Policies (SCPs), and session policies. No manual policy parser accounts for all five layers correctly. The only correct approach is iam:SimulatePrincipalPolicy:

server.tool(
  'get_effective_permissions',
  {
    principal_arn: z.string().min(1),
    action_names: z.array(z.string()).min(1).max(200),
    resource_arns: z.array(z.string()).default(['*'])
  },
  async ({ principal_arn, action_names, resource_arns }) => {
    const results: Array<{ action: string; decision: string; reason: string }> = [];
    const chunks = [];
    for (let i = 0; i < action_names.length; i += 100) {
      chunks.push(action_names.slice(i, i + 100));
    }

    for (const chunk of chunks) {
      const res = await iamClient.send(new SimulatePrincipalPolicyCommand({
        PolicySourceArn: principal_arn,
        ActionNames: chunk,
        ResourceArns: resource_arns
      }));

      for (const result of res.EvaluationResults ?? []) {
        results.push({
          action: result.EvalActionName!,
          decision: result.EvalDecision!,  // 'allowed', 'explicitDeny', 'implicitDeny'
          reason: result.EvalDecisionDetails
            ? JSON.stringify(result.EvalDecisionDetails)
            : result.EvalDecision === 'allowed' ? 'allowed by policy' : 'no matching allow statement'
        });
      }
    }

    return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
  }
);

The EvalDecision field distinguishes between explicitDeny (there is a deny rule that applies) and implicitDeny (there is no allow rule that applies). This distinction matters for privilege escalation analysis: an explicit deny from an SCP cannot be overridden by any identity policy, while an implicit deny can be resolved by adding an allow policy.

SimulatePrincipalPolicy has one documented limitation: it doesn't evaluate resource-based policies (like S3 bucket policies or KMS key policies that allow cross-account access). If you need to account for resource-based policies, you must pass ResourcePolicy and PermissionsBoundaryPolicyInputList explicitly in the request.

OPA: undefined is not false

Open Policy Agent has the most nuanced policy evaluation semantics of the five platforms. When you call POST /v1/data/:path to evaluate a rule, the response result field can have three distinct states that must not be conflated:

OPA result state JSON representation Semantic meaning How to handle
Explicit true {"result": true} A rule matched and returned true Allow
Explicit false {"result": false} A rule matched and returned false Deny (explicit)
Undefined {} (no result key) No rule matched for this input Deny (treat as deny if no default)

The undefined case is where integrations most often break. Many OPA integrations check result === false to detect denial, but this misses the undefined case entirely — a missing result key is treated as allowed when it should be treated as denied. The correct implementation checks whether the result key exists before reading its value:

const res = await opaHttp.post(`/v1/data/${policy_path}`, { input });
const data = res.data;
const resultDefined = 'result' in data;   // check presence, not truthiness

return {
  content: [{
    type: 'text',
    text: JSON.stringify({
      policy_path,
      decision: resultDefined ? data.result : null,
      decision_defined: resultDefined,
      note: resultDefined
        ? undefined
        : 'Rule returned undefined — no rule matched this input. If your policy has no default allow/deny rule, undefined is the same as deny.'
    }, null, 2)
  }]
};

OPA Rego policies with a default allow := false statement will never return undefined for the allow rule — the default catches all unmatched cases. But policies without defaults return undefined for any input that no rule handles, and the behavior at the calling side varies based on whether the caller treats undefined as allow or deny.

Cloudflare Access: policy layering vs session state

Cloudflare Access policies have a three-level evaluation structure that's easy to misread:

  1. Include — the user must match at least one include rule
  2. Exclude — the user must not match any exclude rule
  3. Require — the user must match all require rules

The critical distinction for MCP tools is the difference between a policy check (what does this policy allow?) and a session check (does this user have an active session?). Cloudflare's revoke_user endpoint revokes existing sessions — it does not modify the policy. A user whose session is revoked will be able to authenticate again immediately if they still satisfy the policy. To permanently block a user, you must modify the policy's Exclude list:

// Revoke all active sessions for a user — does NOT change policy
// User can immediately re-authenticate if they satisfy the policy
server.tool(
  'revoke_cloudflare_user_sessions',
  {
    user_email: z.string().email(),
    confirm: z.literal(true)
  },
  async ({ user_email }) => {
    const res = await cfHttp.post(
      `/accounts/${CF_ACCOUNT_ID}/access/organizations/${CF_ORG_ID}/revoke_user`,
      { email: user_email }
    );
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          revoked: res.data.success,
          note: 'Sessions revoked. To permanently block, add user email to an Exclude policy rule.'
        }, null, 2)
      }]
    };
  }
);

Auth0 and Okta: role-based, not policy-based

Auth0 and Okta don't have policy documents in the AWS/OPA sense. Authorization in both systems is role-based: users have roles, roles have permissions, and the effective permission set is the union of all role permissions. For MCP tools, the equivalent of "effective permissions" is listing the roles and their associated permissions for a user:

// Auth0: get user's roles and permissions
server.tool(
  'get_auth0_user_permissions',
  { user_id: z.string().min(1) },
  async ({ user_id }) => {
    const http = await getAuth0Http();
    const [rolesRes, permsRes] = await Promise.all([
      http.get(`/users/${encodeURIComponent(user_id)}/roles`),
      http.get(`/users/${encodeURIComponent(user_id)}/permissions`)
    ]);
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          user_id,
          roles: rolesRes.data.map((r: any) => ({ name: r.name, description: r.description })),
          permissions: permsRes.data.map((p: any) => ({ name: p.permission_name, source_role: p.sources?.[0]?.source_name }))
        }, null, 2)
      }]
    };
  }
);

The Auth0 app_metadata.plan field is the Auth0 equivalent of a policy attribute — it's set by your backend and readable in Rules and Actions to gate access by subscription tier. For Okta, the equivalent is group membership: groups act as the authorization primitive, with users gaining access to applications via group assignment.

Pattern 3: The health probe gap

Every security platform MCP server has a health endpoint. Every one of them has a specific failure mode where the process is running, the health endpoint returns 200, and every tool call is failing. The five failure modes and their correct probes:

Platform Silent failure mode Wrong probe Correct probe
AWS IAM Credentials expired or access key deleted GET /health (process check) sts:GetCallerIdentity (requires valid credentials)
Okta SSWS token revoked or user deactivated GET /api/v1/org (public endpoint, no auth) GET /api/v1/users?limit=1 (requires auth + read:users scope)
Auth0 M2M token expired; tenant rate-limited; wrong scope GET /api/v2/users?per_page=1 (read:users scope) GET /api/v2/tenants/settings (requires read:tenant_settings — specific scope test)
Open Policy Agent Bundles not synced — policy data is stale GET /health (returns 200 if process is alive) GET /health?bundles=true&plugins=true (checks bundle freshness)
Cloudflare API token expired or permissions changed GET /user (lightweight, doesn't check token permissions) GET /user/tokens/verify (returns token status, expiry, and permissions)

AWS IAM: GetCallerIdentity as the canonical credential probe

sts:GetCallerIdentity is the safest IAM health check because it requires no IAM permissions — only valid credentials. If the call succeeds, credentials are valid. If it fails, credentials are expired, deleted, or misconfigured. The error code distinguishes the failure type:

app.get('/health/iam', async (_req, res) => {
  const start = Date.now();
  try {
    const identity = await stsClient.send(new GetCallerIdentityCommand({}));
    res.status(200).json({
      status: 'ok',
      account: identity.Account,
      arn: identity.Arn,
      latency_ms: Date.now() - start
    });
  } catch (err: any) {
    const status = err.name === 'ExpiredTokenException' ? 401 : 503;
    res.status(status).json({
      status: 'error',
      error_code: err.name,
      // ExpiredTokenException = STS session or assumed-role token TTL exceeded
      // InvalidClientTokenId = access key deleted or never existed
      // SignatureDoesNotMatch = wrong secret key in env
      // AccessDenied = SCP is blocking even GetCallerIdentity (rare)
      latency_ms: Date.now() - start
    });
  }
});

OPA: /health?bundles=true&plugins=true is not optional

This is the most commonly misconfigured health probe of the five platforms. OPA's GET /health endpoint returns 200 if and only if the OPA process is running. It says nothing about whether the policy bundles are loaded and current. In a production OPA deployment with bundle-based policy distribution, the process can be running perfectly while serving stale policy data from 24 hours ago — if the bundle server is unreachable.

GET /health?bundles=true&plugins=true checks that all configured bundles have synced at least once and that all plugins are healthy. This is the endpoint AliveMCP should monitor for OPA deployments:

app.get('/health/opa', async (_req, res) => {
  const start = Date.now();
  try {
    // ?bundles=true: all configured bundles must be loaded
    // ?plugins=true: all configured plugins must be healthy
    const opaRes = await opaHttp.get('/health', {
      params: { bundles: 'true', plugins: 'true' }
    });

    res.status(200).json({
      status: 'ok',
      opa_status: opaRes.data,
      latency_ms: Date.now() - start
    });
  } catch (err: any) {
    // OPA returns 500 when bundles are not synced — not 503
    res.status(503).json({
      status: 'error',
      message: 'OPA health check failed — bundles or plugins not ready',
      detail: err.response?.data ?? err.message,
      latency_ms: Date.now() - start
    });
  }
});

Note that OPA returns HTTP 500 (not 503) when bundles are not synced. Some monitoring tools treat 5xx and 4xx differently — ensure your probe reads the OPA response body, not just the HTTP status code, to distinguish "OPA process crashed" (connection refused) from "OPA process running but bundles stale" (500 from the health endpoint).

Okta: the scope validation probe

Okta's GET /api/v1/users?limit=1 probe is more powerful than it looks. It requires two things simultaneously: a valid SSWS token (not revoked, not expired), and a token with sufficient scope to list users. If the admin user tied to the token has been deactivated, or if the token has been explicitly revoked, this call fails. A call to GET /api/v1/org (the Okta tenant metadata endpoint) is public and doesn't validate the token at all:

app.get('/health/okta', async (_req, res) => {
  const start = Date.now();
  try {
    // GET /users?limit=1 validates both token validity AND read:users scope simultaneously
    await oktaHttp.get('/users', { params: { limit: 1 } });
    res.status(200).json({ status: 'ok', latency_ms: Date.now() - start });
  } catch (err: any) {
    const status = err.response?.status;
    const errorCode = err.response?.data?.errorCode;
    res.status(503).json({
      status: 'error',
      http_status: status,
      error_code: errorCode,
      // E0000011: token revoked
      // E0000005: unauthorized (wrong scope or token format)
      // 429: rate limited — return 'degraded', not error
      latency_ms: Date.now() - start
    });
  }
});

Auth0: tenant settings as the specific scope probe

Auth0's GET /api/v2/tenants/settings endpoint requires the read:tenant_settings scope. This is more specific than read:users — an M2M application with only user management permissions won't be able to call this endpoint. This makes it a better probe for validating the full token + scope combination that your MCP server actually needs:

app.get('/health/auth0', async (_req, res) => {
  const start = Date.now();
  try {
    const token = await getM2MToken();
    const http = axios.create({
      baseURL: `https://${AUTH0_DOMAIN}/api/v2`,
      headers: { 'Authorization': `Bearer ${token}` }
    });
    // Requires read:tenant_settings scope — validates token and scope together
    await http.get('/tenants/settings');
    res.status(200).json({ status: 'ok', latency_ms: Date.now() - start });
  } catch (err: any) {
    const status = err.response?.status;
    // 401 = token expired or invalid
    // 403 = token valid but wrong scope (read:tenant_settings not granted)
    // 429 = tenant rate limited — return degraded, not error
    const httpStatus = status === 429 ? 200 : 503;
    res.status(httpStatus).json({
      status: status === 429 ? 'degraded' : 'error',
      http_status: status,
      latency_ms: Date.now() - start
    });
  }
});

Cloudflare: token verify with expiry pre-warning

The Cloudflare GET /user/tokens/verify endpoint is the only endpoint in the Cloudflare API that explicitly surfaces the token's status field (active, disabled, expired) and its expires_on timestamp. This enables proactive monitoring — AliveMCP can alert you days before a Cloudflare API token expires rather than discovering the expiry when automation breaks:

app.get('/health/cloudflare', async (_req, res) => {
  const start = Date.now();
  try {
    const verifyRes = await cfHttp.get('/user/tokens/verify');
    const token = verifyRes.data.result;

    const expiresOn = token.expires_on ? new Date(token.expires_on) : null;
    const daysUntilExpiry = expiresOn
      ? Math.floor((expiresOn.getTime() - Date.now()) / 86_400_000)
      : null;

    res.status(200).json({
      status: token.status === 'active' ? 'ok' : 'error',
      token_status: token.status,
      expires_on: token.expires_on ?? 'no expiry set',
      days_until_expiry: daysUntilExpiry,
      // AliveMCP can alert at 14 days, 7 days, 1 day thresholds
      expiry_warning: daysUntilExpiry !== null && daysUntilExpiry < 14,
      latency_ms: Date.now() - start
    });
  } catch (err: any) {
    res.status(503).json({
      status: 'error',
      error: err.message,
      latency_ms: Date.now() - start
    });
  }
});

Where the five platforms diverge: mutation safety

Beyond the three shared patterns, the platforms differ significantly in how mutations should be guarded. Security platform mutations are uniquely dangerous because they're hard to reverse and have immediate, broad effects:

Platform Dangerous operations Guard mechanism Reversal path
AWS IAM create_access_key, create_policy_version, attach_role_policy confirm: z.literal(true) on all key creation Delete key, revert policy version
Okta suspend_user, deactivate_user, reset_password confirm: z.literal(true) on lifecycle operations Unsuspend/reactivate via lifecycle API
Auth0 block_user, delete_user, remove_role confirm: z.literal(true) on block/delete Unblock via PATCH /users/:id; delete is permanent
OPA update_policy, delete_policy confirm: z.literal(true) on policy writes Re-PUT previous Rego source (keep versions in git)
Cloudflare revoke_user_sessions, create_access_policy, delete_access_app confirm: z.literal(true) on revoke/delete Session revocation is permanent; policy is reversible

The confirm: z.literal(true) pattern is the MCP-idiomatic way to require explicit confirmation before dangerous mutations. Because LLMs generate tool arguments automatically, a stray call to suspend_user without a confirm guard can deactivate a production account before any human reviews the action. Requiring confirm: true as a literal (not a boolean) forces the LLM to explicitly reason about the guard and include it intentionally:

server.tool(
  'suspend_okta_user',
  {
    user_id: z.string().min(1),
    reason: z.string().min(10),  // require a reason to be logged
    confirm: z.literal(true)      // must be exactly true, not "true" or 1
  },
  async ({ user_id, reason }) => {
    await oktaHttp.post(`/users/${encodeURIComponent(user_id)}/lifecycle/suspend`);
    console.log(`[audit] suspended user ${user_id}: ${reason}`);
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ suspended: true, user_id, reason })
      }]
    };
  }
);

Privilege escalation: the AWS IAM-specific pattern

AWS IAM has a risk that the other four platforms don't expose directly: privilege escalation via policy manipulation. A principal with the right combination of lower-privilege IAM permissions can effectively grant themselves administrator access. The six most common escalation paths:

const ESCALATION_PATTERNS = [
  {
    name: 'CreateRole + AttachRolePolicy',
    required: ['iam:CreateRole', 'iam:AttachRolePolicy'],
    description: 'Can create a new role with an admin policy attached, then assume it'
  },
  {
    name: 'CreatePolicyVersion',
    required: ['iam:CreatePolicyVersion'],
    description: 'Can overwrite an existing policy with a new version granting more permissions'
  },
  {
    name: 'PutUserPolicy / PutRolePolicy',
    required: ['iam:PutUserPolicy'],
    description: 'Can write a new inline policy directly to any user or role'
  },
  {
    name: 'AddUserToGroup',
    required: ['iam:AddUserToGroup'],
    description: 'Can add self to a group that has admin permissions'
  },
  {
    name: 'PassRole + EC2',
    required: ['iam:PassRole', 'ec2:RunInstances'],
    description: 'Can launch an EC2 instance with an admin role attached, then connect to it'
  },
  {
    name: 'UpdateLoginProfile',
    required: ['iam:UpdateLoginProfile'],
    description: 'Can change another IAM user\'s console password and log in as them'
  }
];

server.tool(
  'detect_privilege_escalation',
  { principal_arn: z.string().min(1) },
  async ({ principal_arn }) => {
    const allActions = [...new Set(ESCALATION_PATTERNS.flatMap(p => p.required))];

    const res = await iamClient.send(new SimulatePrincipalPolicyCommand({
      PolicySourceArn: principal_arn,
      ActionNames: allActions,
      ResourceArns: ['*']
    }));

    const allowedActions = new Set(
      (res.EvaluationResults ?? [])
        .filter(r => r.EvalDecision === 'allowed')
        .map(r => r.EvalActionName!)
    );

    const risks = ESCALATION_PATTERNS.filter(pattern =>
      pattern.required.every(action => allowedActions.has(action))
    );

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          principal: principal_arn,
          escalation_risk_count: risks.length,
          risks: risks.map(r => ({
            pattern: r.name,
            description: r.description,
            required_permissions: r.required
          }))
        }, null, 2)
      }]
    };
  }
);

This tool is only meaningful when used with iam:SimulatePrincipalPolicy — not with a manual policy document reader. SCPs can block escalation patterns even when the identity policy allows them; only the simulation API accounts for both layers simultaneously.

Minimum required permissions per platform

A common mistake when deploying security platform MCP servers is giving the MCP service identity overly broad permissions "to avoid problems." The minimum required permissions for each read-only integration:

AWS IAM (read-only + STS)

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "iam:ListPolicies",
      "iam:ListAttachedUserPolicies",
      "iam:ListUserPolicies",
      "iam:GetUserPolicy",
      "iam:SimulatePrincipalPolicy",
      "sts:GetCallerIdentity",
      "sts:AssumeRole"
    ],
    "Resource": "*"
  }]
}

Okta (read-only)

Create an SSWS token tied to a service account with only the following OAuth scopes: okta.users.read, okta.groups.read. The token should NOT have okta.users.manage unless the MCP server needs to perform lifecycle operations.

Auth0 (read-only)

Create a Machine-to-Machine application with only: read:users, read:roles, read:tenant_settings. Do NOT grant update:users or delete:users to read-only MCP servers.

OPA

OPA's bearer token authentication (if configured) doesn't have scopes — it's all-or-nothing. For read-only access, configure OPA's built-in authorization policy to block PUT /v1/policies and DELETE /v1/policies for the MCP server's token while allowing POST /v1/data and GET /v1/policies.

Cloudflare Access (read-only)

Create a scoped API token with only: Account > Access: Apps and Policies > Read. The token should NOT have Edit permission unless the MCP server needs to modify policies.

Wiring AliveMCP to security platform health endpoints

The five health probes described in this guide represent the five different failure modes that security platform MCP servers experience in production. Configure AliveMCP to poll all five:

Platform Endpoint to monitor What it catches Recommended poll interval
AWS IAM /health/iamsts:GetCallerIdentity Credential expiry, key deletion, SCP block 60s
Okta /health/oktaGET /api/v1/users?limit=1 Token revocation, user deactivation, scope change 60s
Auth0 /health/auth0GET /api/v2/tenants/settings Token expiry, scope mismatch, tenant rate limit 60s
OPA /health/opaGET /health?bundles=true&plugins=true Bundle staleness, plugin health, process crash 30s (bundles can go stale silently)
Cloudflare /health/cloudflareGET /user/tokens/verify Token status, expiry date, permission changes 300s (token status changes slowly)

Each of these endpoints will return HTTP 200 when things are working and a non-200 status when the specific failure mode has occurred. The most important insight from operating these integrations: the failure mode that causes the most production incidents is always the one that the process health check doesn't catch. A running process is not a working integration.

Frequently asked questions

Should I use a service account SSWS token or OAuth for Okta in production?

SSWS API tokens are appropriate for server-to-server integrations like MCP tools. They're long-lived, don't require OAuth flow management, and are tied to a specific admin user's permissions. The risk is that if the tied admin user is deactivated or their role changes, the token breaks silently until your health probe catches it. For this reason: (1) use a dedicated service account as the SSWS token owner, not a real employee's account; (2) give the service account only the minimum required scopes; (3) monitor with GET /api/v1/users?limit=1 so you catch revocations immediately. OAuth with client credentials is an alternative but requires Okta API Access Management, which is a paid add-on.

What's the difference between OPA's /v1/data and /v1/query endpoints?

POST /v1/data/:path evaluates a named rule at a specific package path — it's the endpoint for production policy decisions. POST /v1/query accepts an ad-hoc Rego query string and evaluates it against the current data — it's designed for debugging and interactive exploration, not production use. The distinction matters because /v1/query accepts arbitrary Rego, which means it could be exploited to exfiltrate data from the OPA data layer if exposed without authentication. In MCP tools, always use /v1/data/:path with pre-defined policy paths, never /v1/query with LLM-generated Rego strings.

Can I evaluate Cloudflare Access JWT validity locally without calling the API?

Yes, and you should. Cloudflare issues JWTs for Access sessions signed with a key pair. The public keys are available at https://<team-domain>.cloudflareaccess.com/cdn-cgi/access/certs as a JWKS endpoint. Cache those keys locally (they rotate infrequently) and validate incoming JWTs using a library like jose (jwtVerify with the JWKS). Local validation is faster (no network call per request) and not subject to rate limits. The only time you need to call /user/tokens/verify is in your health probe to check your service's own API token status — not for validating end-user Access JWTs.

Why does Auth0 have two separate rate limits — one for the Management API and one for the token endpoint?

Auth0's /oauth/token endpoint is separate infrastructure from the Management API. The token endpoint rate limit (30 req/min) exists to prevent brute-force credential stuffing against client credentials. The Management API rate limit (15 req/sec burst, varies by endpoint) is to protect tenant resource usage. The practical implication: if you cache M2M tokens correctly (cache until 60 seconds before expiry), you'll never hit the token endpoint rate limit in normal operation. You'll only hit it if your cache is being reset incorrectly — for example, if you're creating a new axios instance per request instead of reusing a cached one. The token endpoint limit is a canary for a caching bug, not a capacity limit you'll hit legitimately.

Is there a cross-platform way to handle "principal not found" errors in MCP tools?

Each platform returns a different status code and error body for not-found principals. AWS IAM doesn't have a "user not found" error for SimulatePrincipalPolicy — it returns a successful evaluation with all actions as implicitDeny if the ARN doesn't exist. Okta returns HTTP 404 with errorCode: E0000007. Auth0 returns HTTP 404 with {"statusCode":404,"error":"Not Found"}. Cloudflare returns HTTP 404 inside its standard response envelope. For MCP tools, the correct approach is to catch 404 responses and throw new McpError(ErrorCode.InvalidParams, 'User not found: ...') so the LLM receives an actionable error message rather than an unhandled promise rejection. Never let the platform's raw 404 response propagate as an uncaught error.

Further reading

Know when your security platform credentials expire before your agent workflows find out the hard way

AliveMCP polls your /health/iam, /health/okta, /health/auth0, /health/opa, and /health/cloudflare endpoints and alerts you the moment credentials expire, tokens are revoked, or bundles go stale.

Start monitoring free