Deep Dive · AWS IAM & STS

IAM and STS for MCP Servers: Role Assumption, Temporary Credentials, Permission Boundaries, Resource Policies, and Session Policies — Five Access Control Patterns

Published 2026-09-18 · 14 min read

An MCP server that orchestrates AWS resources on behalf of users or tenants must answer a hard question: which AWS account, which resources, and which operations are authorized for this tool call? The stakes are high — a misconfigured IAM policy can expose one tenant's data to another, allow privilege escalation from a tool call to an admin role, or let credentials expire mid-operation and corrupt shared state. AWS IAM and STS together provide five interlocking mechanisms for safe, auditable, least-privilege multi-tenant access: role assumption with ExternalId, STS temporary credential management with proactive refresh, permission boundaries for delegated IAM administration, resource-based policies for cross-account access without role assumption, and session policies for per-call tenant scoping. This post synthesizes the production-critical decisions in each mechanism.

Pattern 1: AssumeRole with ExternalId for cross-account MCP tool access

The baseline pattern for cross-account MCP tool access is sts:AssumeRole: the MCP server's execution role calls STS to get temporary credentials for a role in the customer's account. No long-lived keys are stored; every credential is time-bounded and logged in CloudTrail; and the blast radius of a compromised server is limited to the permissions of the assumed roles.

The critical addition for SaaS MCP platforms is ExternalId in the trust policy. Without ExternalId, any AWS principal with sts:AssumeRole permission on the customer's role can assume it — including a malicious AWS customer who has configured their own role to trust your platform account. ExternalId is a per-customer secret embedded in the trust policy's Condition block. The platform generates it at customer onboarding and passes it in every AssumeRole call. An attacker setting up a malicious role cannot replicate a victim customer's ExternalId:

// Trust policy with ExternalId (in customer account)
{
  "Principal": { "AWS": "arn:aws:iam::MCP_PLATFORM_ACCOUNT:role/mcp-execution" },
  "Action": "sts:AssumeRole",
  "Condition": {
    "StringEquals": { "sts:ExternalId": "per-customer-secret-uuid" }
  }
}

// AssumeRole call (in MCP server)
const result = await sts.send(new AssumeRoleCommand({
  RoleArn: `arn:aws:iam::${customerAccount}:role/mcp-integration-role`,
  RoleSessionName: `mcp-${customerId}-${toolName}-${Date.now()}`,
  ExternalId: await getExternalIdForCustomer(customerId),
  DurationSeconds: 3600,
}));

Three role assumption constraints catch teams by surprise in production:

  1. Role chaining caps sessions at 1 hour. If the MCP server's execution role was itself assumed (e.g., it's a GitHub Actions OIDC session), then the downstream AssumeRole returns credentials capped at 1 hour regardless of the DurationSeconds or the target role's MaxSessionDuration. Detect this by comparing the returned Expiration to what you requested. Flatten the chain if longer sessions are needed.
  2. RoleSessionName has a strict character set. Only [\w+=,.@-]{2,64} — no slashes, no spaces, no colons. Use hyphens as separators in the customerId-toolName-timestamp pattern.
  3. SCPs apply silently. An SCP in the target account's organization can deny actions that the assumed role's permission policy allows. The denial appears as AccessDenied with no indication that an SCP caused it. Run SimulatePrincipalPolicy with SCP content before deploying privileged tool calls.

→ Full reference: IAM role assumption patterns for MCP servers

Pattern 2: STS temporary credential management and proactive refresh

STS credentials expire. The only question is whether the MCP server handles expiry gracefully (refreshing before the deadline) or badly (letting an in-flight tool call fail with ExpiredTokenException). The right answer depends on understanding the AWS SDK's credential provider chain and implementing proactive refresh.

The credential provider chain tries sources in a fixed order: environment variables → shared credentials file → SSO → process credentials → web identity token file (OIDC) → container metadata → EC2 instance metadata. For Lambda and ECS, the first entry that succeeds is the execution role credentials injected by the service — do not override them. For EKS with IRSA, the SDK automatically exchanges the projected OIDC token at AWS_WEB_IDENTITY_TOKEN_FILE for STS credentials when AWS_ROLE_ARN is set; this happens before container metadata in the chain.

// Proactive refresh at 80% of credential lifetime
function scheduleRefresh(credentials) {
  const expiry = new Date(credentials.Expiration).getTime();
  const lifetime = expiry - Date.now();
  setTimeout(() => refreshAndReschedule(), lifetime * 0.8);
}

// Or use fromTemporaryCredentials — SDK handles refresh automatically
import { fromTemporaryCredentials } from "@aws-sdk/credential-providers";
const creds = fromTemporaryCredentials({
  params: { RoleArn, RoleSessionName, ExternalId, DurationSeconds: 3600 }
});

For EKS IRSA, the trust policy sub condition must match system:serviceaccount:{namespace}:{service-account-name} exactly. The OIDC provider ARN must match the cluster's issuer URL including the trailing path segment — a common mistake is using the EKS cluster OIDC endpoint URL without the /id/CLUSTERID path suffix. For GitHub Actions OIDC, the sub includes the branch: repo:{org}/{repo}:ref:refs/heads/{branch} — always scope to a specific branch or environment, not just the repo.

AliveMCP monitoring note: Credential expiry during long-running tool calls is the most common IAM-related incident in MCP server deployments. AliveMCP monitors whether tool call sessions are completing before their credential windows expire and alerts when tool durations approach the credential lifetime threshold.

→ Full reference: STS temporary credentials for MCP servers

Pattern 3: Permission boundaries for delegated IAM administration

An MCP server that creates IAM roles on behalf of customers (for Lambda execution roles, service integrations, cross-account access) needs a mechanism to prevent privilege escalation: a compromised tool call should not be able to create an admin role. Permission boundaries are the mechanism.

A permission boundary is a managed policy attached to an IAM role that defines its maximum effective permissions. The effective permissions are the intersection of the identity policy and the boundary — the boundary never grants, it only restricts. For delegated IAM admin, the key constraint is the iam:PermissionsBoundary condition on iam:CreateRole: the MCP server can only create roles if a specific boundary policy is attached in the same call:

// MCP server's executor policy: can only create roles with the boundary attached
{
  "Action": ["iam:CreateRole", "iam:AttachRolePolicy"],
  "Resource": "arn:aws:iam::*:role/mcp-created-*",
  "Condition": {
    "StringEquals": {
      "iam:PermissionsBoundary": "arn:aws:iam::ACCOUNT:policy/mcp-tool-boundary"
    }
  }
}

// CRITICAL: prevent detaching the boundary post-creation
{
  "Effect": "Deny",
  "Action": "iam:DeleteRolePermissionsBoundary",
  "Resource": "arn:aws:iam::*:role/mcp-created-*"
}

The permissions intersection model has one non-obvious property: resource-based policies (S3 bucket policies, Lambda resource policies) can still allow access to a bounded principal for same-account cross-service operations — the boundary only restricts what the bounded principal can do when IAM is the authorization mechanism for its own requests. An S3 bucket that grants access to everyone in the account bypasses the role's boundary for that bucket access.

IAM Access Analyzer's CheckAccessNotGranted API validates that a policy document cannot grant specified privileged actions — run this before every iam:PutRolePolicy or iam:AttachRolePolicy call in tools that create customer IAM policies. The continuous external access analyzer catches resource-based policies that grant cross-account access, which is valuable for detecting misconfigurations created by MCP tool calls.

→ Full reference: IAM permission boundaries for MCP servers

Pattern 4: Resource-based policies for cross-account access without role assumption

For read-heavy integrations where the MCP server accesses customer data without modifying IAM configuration, resource-based policies offer a simpler alternative to role assumption: the customer sets a bucket policy (or Lambda resource policy, or SQS queue policy) that directly grants the MCP server's execution role access to their resources.

The critical rule: for cross-account access, both a resource-based policy on the target resource AND an identity-based policy on the calling role are required. Either alone is insufficient. For same-account access, either one alone is sufficient — the S3 bucket policy or the role policy is enough, but not both required.

// Cross-account access: BOTH required
// Customer bucket policy (account B):
{ "Principal": { "AWS": "arn:aws:iam::ACCOUNT_A:role/mcp-execution" },
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": ["arn:aws:s3:::customer-bucket", "arn:aws:s3:::customer-bucket/*"],
  "Condition": { "StringEquals": { "aws:PrincipalAccount": "ACCOUNT_A" } } }

// MCP execution role policy (account A):
{ "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": ["arn:aws:s3:::customer-bucket", "arn:aws:s3:::customer-bucket/*"] }

A decision matrix for when to use resource policies vs. role assumption:

CriterionResource PolicyRole Assumption
Setup complexityLow — customer sets bucket/queue policyMedium — customer creates IAM role with trust policy
Credential handlingNone — MCP server uses its own execution roleRequired — manage per-customer STS credentials
Resource scopePer-resource (each resource needs a policy)Account-wide (role policy scopes all resources)
Audit trailMCP server account in CloudTrailCustomer account in CloudTrail via session name
ExternalId protectionNone (use aws:PrincipalAccount condition instead)ExternalId in trust policy
IAM writes (create roles, modify policies)Cannot — bucket policies don't grant IAM accessYes — assumed role can have iam:* if needed
Many resources per tenantPolicy per resource — update burdenSingle role assumption covers all resources

Lambda resource policies use the AddPermission API, not a generic PutPolicy. The permission action for Lambda URL invocations is lambda:InvokeFunctionUrl — different from lambda:InvokeFunction used for direct Lambda invocations. For SQS queues with service principals (SNS, EventBridge), always include aws:SourceArn condition — otherwise any SNS topic in any account can send to the queue by referencing its ARN.

→ Full reference: IAM resource-based policies for MCP servers

Pattern 5: Session policies for per-call tenant isolation

The role assumption pattern (Pattern 1) works when the MCP server assumes a different role for each customer account. But when a single execution role handles multiple tenants within the same account — a common architecture for cost and simplicity — you need per-call scoping. Session policies are the mechanism: an inline policy document (or managed policy reference) passed in the AssumeRole call that further restricts what the resulting session can do.

The safety property: session policies can only restrict permissions, never grant them. The effective permissions are the intersection of the base role policy, any permission boundary, any SCPs, and the session policy. A malicious tool call parameter that injects arbitrary values into resource ARNs hits the session policy's resource conditions before the API call reaches S3 or DynamoDB:

// Per-tenant scoped session (single account, multi-tenant)
const sessionPolicy = JSON.stringify({
  Version: "2012-10-17",
  Statement: [
    {
      Effect: "Allow",
      Action: ["s3:GetObject", "s3:PutObject"],
      Resource: `arn:aws:s3:::mcp-data/tenants/${tenantId}/*`
    },
    {
      Effect: "Allow",
      Action: ["dynamodb:GetItem", "dynamodb:PutItem"],
      Resource: "arn:aws:dynamodb:us-east-1:ACCOUNT:table/mcp-tools",
      Condition: {
        "ForAllValues:StringLike": {
          "dynamodb:LeadingKeys": [`${tenantId}#*`]
        }
      }
    }
  ]
});

await sts.send(new AssumeRoleCommand({
  RoleArn: "arn:aws:iam::ACCOUNT:role/mcp-multi-tenant-role",
  RoleSessionName: `mcp-${tenantId}-${Date.now()}`,
  Policy: sessionPolicy,   // JSON string, must be < 2,048 characters
  DurationSeconds: 900,    // 15 minutes — short-lived per-call credential
}));

When session policies grow beyond 2,048 characters, switch to the policy variable approach: pass tenant identity as session tags (Tags: [{ Key: "TenantId", Value: tenantId }]), and write the base role's permission policy to use ${aws:PrincipalTag/TenantId} as a dynamic resource ARN variable. The role policy becomes static ("Resource": "arn:aws:s3:::mcp-data/tenants/${aws:PrincipalTag/TenantId}/*"), and the session tags drive the scoping at request evaluation time. This approach has no per-call size limit and avoids the latency of per-call policy generation.

For DynamoDB per-tenant isolation, dynamodb:LeadingKeys with ForAllValues:StringLike restricts which partition keys the session can read or write. The key insight: the condition enforces at the IAM layer — a tool parameter injection that tries to access another tenant's partition key fails at IAM authorization, before DynamoDB even evaluates the request.

→ Full reference: IAM session policies for MCP servers

Combined failure mode reference (all 5 patterns)

PatternFailureSymptomFix
Role assumptionExternalId missingConfused deputy attack possible — any account can assume the roleAdd StringEquals sts:ExternalId condition to trust policy; generate per-customer at onboarding
Role assumptionRole chaining 1-hour capRequested 12h credentials, received 1hFlatten the chain or implement 80% lifetime refresh
Role assumptionSCP silent denialAccessDenied with no hint of causeRun SimulatePrincipalPolicy including SCP content
STS credentialsExpiredTokenException mid-callLong tool call fails after 60 minutesUse fromTemporaryCredentials or refresh at 80% of lifetime
STS credentialsIRSA token file not mountedPod uses node role instead of service account roleCheck service account annotation and projected volume mount
Permission boundariesBoundary not attached at creationCreated role has no ceiling — can be granted adminUse iam:PermissionsBoundary condition on iam:CreateRole; boundary must be in the CreateRole call, not added after
Permission boundariesBoundary detached post-creationiam:DeleteRolePermissionsBoundary removes ceilingAdd Deny on iam:DeleteRolePermissionsBoundary for mcp-created-* role prefix
Resource policiesCross-account missing identity policyBucket policy allows role but access still deniedCross-account requires both resource policy AND identity policy; add S3 permissions to role policy
Resource policiesLambda URL wrong actionInvokeFunction policy set, URL invocation returns 403Lambda URL requires lambda:InvokeFunctionUrl, not lambda:InvokeFunction
Session policiesPolicy exceeds 2,048 charsAssumeRole ValidationError on complex tenant policyUse PolicyArns or switch to policy variable approach with session tags
Session policiesdynamodb:LeadingKeys format wrongDynamoDB access fails for valid keysLeadingKeys must match partition key value; use ForAllValues:StringLike for prefix matching
Session policiesPolicy variable not resolving${aws:PrincipalTag/TenantId} evaluates as literal stringTags must be passed in Tags[] parameter of AssumeRole, not in session policy body

Pattern selection guide

MCP tool scenarioRecommended patternWhy
Read customer S3 bucket (read-only integration)Resource policy (Pattern 4)Simpler setup; no credential management; customer sets bucket policy once
Manage customer AWS resources (IAM, EC2, RDS)Role assumption + ExternalId (Pattern 1)Audit trail in customer account; per-customer blast radius; ExternalId prevents confused deputy
MCP server creates Lambda/ECS roles for customersPermission boundaries (Pattern 3)Prevents privilege escalation via created roles; iam:PermissionsBoundary condition enforces ceiling at creation time
Multi-tenant tool calls within single accountSession policies (Pattern 5)No per-tenant role needed; session policy intersection enforces isolation at IAM layer
EKS-hosted MCP server needing AWS accessIRSA / AssumeRoleWithWebIdentity (Pattern 2)No credential management; OIDC token auto-rotated by kubelet; scoped to specific service account
High-tenant-count, complex per-tenant scopingSession tags + policy variables (Pattern 5 variant)Avoids 2,048-char session policy limit; static role policy uses ${aws:PrincipalTag} for dynamic scoping
GitHub Actions CI/CD deploying MCP infrastructureOIDC AssumeRoleWithWebIdentity (Pattern 2)No long-lived keys; branch-scoped trust policy; token issued per workflow run