Guide · AWS IAM

MCP Server IAM Permission Boundaries — least-privilege delegation and Access Analyzer

An MCP server that creates IAM roles or policies on behalf of customers faces an escalation problem: if the server's execution role has iam:CreateRole and iam:AttachRolePolicy, what stops a malicious tool call from creating an admin role? Permission boundaries are IAM's answer. A permission boundary is a managed policy attached to a role (or user) that defines the maximum permissions the entity can ever have — the effective permissions are the intersection of the boundary and the entity's identity-based policies. Even if the role's permission policy allows s3:*, a boundary that only allows s3:GetObject and s3:PutObject silently blocks all other S3 actions. For MCP servers that perform delegated IAM operations — creating service roles for customers, provisioning least-privilege roles for Lambda functions, or vending temporary access roles — permission boundaries are the mechanism that prevents privilege escalation without requiring a human approval step for every role creation.

TL;DR

Effective permissions = identity policy ∩ permission boundary (∩ SCPs if applicable). The boundary sets a ceiling — it never grants permissions on its own. For delegated IAM admin, require the MCP server to attach a specific boundary policy to any role it creates (iam:PassRole with iam:PassedToService condition plus a iam:PermissionsBoundary condition on iam:CreateRole). Use IAM Access Analyzer to validate that created roles cannot access resources outside the intended scope.

The permissions intersection model

IAM evaluates permissions using a logical AND across all applicable policy types. When a permission boundary is attached to a role:

// Effective permissions = intersection of ALL applicable policy types
//
// Identity-based policy (attached to role):
//   Allow: s3:*, ec2:DescribeInstances
//
// Permission boundary (managed policy attached as boundary):
//   Allow: s3:GetObject, s3:PutObject, cloudwatch:PutMetricData
//
// Effective permissions (intersection):
//   s3:GetObject, s3:PutObject   ← in BOTH identity policy AND boundary
//
// BLOCKED despite identity policy allowing it:
//   s3:DeleteObject              ← allowed by identity policy, NOT in boundary
//   ec2:DescribeInstances        ← allowed by identity policy, NOT in boundary
//
// The boundary never grants — it only limits
// An action not in the identity policy is denied even if in the boundary

// SCP further restricts the effective set:
// Effective = identity policy ∩ permission boundary ∩ SCP

// Resource-based policies can allow access even when identity policy denies,
// BUT a permission boundary blocks even resource-based policy grants
// for actions on the bounded principal's own behalf

One critical nuance: permission boundaries do not affect resource-based policies when the caller is accessing a resource in the same account. If an S3 bucket policy allows s3:GetObject to everyone in the account, a role with a boundary that excludes S3 can still read that bucket — the bucket policy grants access independently of the role's boundary. Boundaries only restrict what the bounded principal can do when its own identity is the authorization mechanism.

Delegated IAM admin: the boundary enforcement pattern

The delegated IAM admin pattern allows the MCP server to create IAM roles for customers while guaranteeing those roles cannot exceed a predefined privilege ceiling. The key is a Condition on the iam:CreateRole permission that requires a specific boundary policy ARN to be attached:

// MCP server's execution role policy — allows IAM operations but only
// when a specific permission boundary is attached to the created role
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["iam:CreateRole", "iam:PutRolePolicy", "iam:AttachRolePolicy"],
      "Resource": "arn:aws:iam::*:role/mcp-created-*",
      "Condition": {
        // The iam:PermissionsBoundary condition key requires that the specified
        // boundary be attached when the role is created. Without this condition,
        // the created role inherits no boundary and could be granted admin access.
        "StringEquals": {
          "iam:PermissionsBoundary": "arn:aws:iam::111122223333:policy/mcp-tool-boundary"
        }
      }
    },
    {
      "Effect": "Allow",
      "Action": "iam:DeleteRole",
      "Resource": "arn:aws:iam::*:role/mcp-created-*"
    },
    {
      // Prevent removing the boundary from roles the MCP server creates
      // Without this, the server could create a bounded role then detach the boundary
      "Effect": "Deny",
      "Action": "iam:DeleteRolePermissionsBoundary",
      "Resource": "arn:aws:iam::*:role/mcp-created-*"
    }
  ]
}

// The boundary policy attached to created roles (mcp-tool-boundary):
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject", "s3:PutObject", "s3:ListBucket",
        "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query",
        "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents",
        "cloudwatch:PutMetricData"
      ],
      "Resource": "*"
    }
    // No iam:* — roles created by the MCP server cannot themselves
    // create or modify IAM entities (prevents privilege escalation chain)
  ]
}

The Deny on iam:DeleteRolePermissionsBoundary is the critical lock. Without it, a compromised MCP server could create a role with the required boundary, then immediately remove the boundary using a second API call — leaving an unbounded role with whatever identity policies were attached.

iam:PassRole with iam:PassedToService

MCP tools that launch Lambda functions, ECS tasks, or EC2 instances need iam:PassRole to attach an execution role to the service. Without iam:PassedToService conditions, a server with iam:PassRole: * can pass any role — including highly privileged roles — to any service. Scoping with iam:PassedToService limits this:

// Scoped iam:PassRole — prevents passing privileged roles to unexpected services
{
  "Effect": "Allow",
  "Action": "iam:PassRole",
  "Resource": "arn:aws:iam::111122223333:role/mcp-lambda-execution-*",
  "Condition": {
    "StringEquals": {
      // Only allow passing these roles to Lambda — not to EC2, ECS, or other services
      "iam:PassedToService": "lambda.amazonaws.com"
    }
  }
}

// For passing roles to multiple services (e.g., Lambda and Step Functions):
"iam:PassedToService": [
  "lambda.amazonaws.com",
  "states.amazonaws.com"
]

// If the MCP tool needs to pass roles to ECS tasks:
"iam:PassedToService": "ecs-tasks.amazonaws.com"
// NOTE: The service principal for ECS task roles is "ecs-tasks.amazonaws.com"
// NOT "ecs.amazonaws.com" (which is the ECS service principal for cluster operations)

IAM Access Analyzer for boundary validation

IAM Access Analyzer validates that an IAM policy does not grant access to external principals or exceed intended scope. For MCP servers, the most useful analyzer type is the custom policy check which validates a policy against a reference policy (the permission boundary) before deployment:

import { AccessAnalyzerClient, CheckAccessNotGrantedCommand } from "@aws-sdk/client-accessanalyzer";

const analyzer = new AccessAnalyzerClient({ region: "us-east-1" });

// Validate that a tool-created role policy does not exceed the boundary
async function validatePolicyAgainstBoundary(
  policyDocument: string,
  boundaryArn: string
) {
  // CheckAccessNotGranted confirms that the specified actions are NOT
  // grantable by the given policy — use this to verify the policy
  // cannot perform privileged actions outside the boundary
  const result = await analyzer.send(new CheckAccessNotGrantedCommand({
    policyDocument: policyDocument,
    access: [
      // Verify the policy cannot perform IAM mutations
      { actions: ["iam:CreateUser", "iam:CreateRole", "iam:AttachUserPolicy"] },
      // Verify the policy cannot exfiltrate data to external accounts
      { actions: ["s3:PutBucketPolicy"], resources: ["*"] },
    ],
    policyType: "IDENTITY_POLICY"
  }));

  // result.result: "PASS" | "FAIL"
  if (result.result === "FAIL") {
    throw new Error(
      `Policy grants privileged access: ${JSON.stringify(result.reasons)}`
    );
  }
}

// Run before iam:PutRolePolicy or iam:AttachRolePolicy in any tool
// that creates or modifies IAM policies on behalf of customers

Access Analyzer also provides external access findings: resource policies (S3 bucket policies, KMS key policies, Lambda resource policies) that grant access to principals outside the account. Enable Access Analyzer in every region your MCP tools operate in — it runs continuously and generates findings when a new cross-account resource policy is created.

Failure modes reference

FailureSymptomFix
Boundary not attached at creation timeiam:CreateRole succeeds without boundary; iam:PermissionsBoundary condition on the executor's policy is not triggered post-creationThe condition on iam:CreateRole must use iam:PermissionsBoundary — not a separate iam:PutRolePermissionsBoundary call; the boundary must be specified in the CreateRole API call itself
Boundary detached after creationiam:DeleteRolePermissionsBoundary called on a created role removes the ceilingAdd Deny on iam:DeleteRolePermissionsBoundary scoped to the mcp-created-* role name prefix in the executor's policy
Effective permissions unexpectedly blockedRole has both identity policy and boundary allowing an action, but API call still returns AccessDeniedCheck SCPs — the intersection includes SCPs; an SCP Deny overrides both identity policy and boundary Allow; use SimulatePrincipalPolicy with SCP content to confirm
Resource-based policy bypass misunderstoodBounded role can still access S3 bucket that allows the account — team believes boundary prevents thisBoundaries restrict what the bounded principal can do when IAM is the authorization mechanism; same-account resource-based policies bypass the boundary for cross-service access
iam:PassRole denied despite correct permissionsLambda CreateFunction fails with iam:PassRole not authorizedCheck iam:PassedToService condition — must be exactly "lambda.amazonaws.com"; check Resource ARN in the PassRole statement matches the role being passed
Access Analyzer CheckAccessNotGranted false negativePolicy passes validation but grants unexpected access via resource-based policyAccess Analyzer policy checks evaluate identity policies only — run a separate analyzer for resource-based policy cross-account access (external access findings)