Guide · AWS IAM & STS

MCP Server IAM Role Assumption — AssumeRole patterns for cross-account tool access

An MCP server that manages resources across multiple AWS accounts needs a reliable way to acquire scoped, short-lived credentials for each target account without storing long-lived IAM user keys. The AWS STS AssumeRole API is the canonical solution: the MCP server (running in account A) calls sts:AssumeRole on a role in account B, receives temporary credentials valid for 15 minutes to 12 hours, and uses those credentials for all tool calls targeting account B. When the credentials approach expiry, the server calls AssumeRole again. No keys are stored; blast radius is bounded by the role's permission policy; and every assumption is logged in CloudTrail. The key design decisions are: trust policy construction (who can assume the role), ExternalId for confused-deputy protection in SaaS integrations, role chaining depth limits, and session duration arithmetic when chaining through multiple roles.

TL;DR

Call sts:AssumeRole from the MCP server's execution role; cache the returned Credentials object and refresh when Expiration is within 5 minutes. For SaaS multi-tenant integrations use ExternalId in the trust policy — require it, and store the per-customer value outside IAM. Role chaining caps at 1 hour regardless of DurationSeconds — if your deepest chain step needs longer sessions, flatten the chain. SCPs in the target account silently reduce what the assumed role can do even if the role's permission policy allows it — always simulate with both the role policy and relevant SCPs before deploying.

Trust policy construction: who can assume the role

The trust policy (also called the assume-role policy document) is the resource-based policy attached to the IAM role that specifies which principals are allowed to call sts:AssumeRole on it. It is separate from the role's permission policy — the permission policy says what the role can do; the trust policy says who can become the role.

For cross-account access from an MCP server Lambda, the trust policy principal is the Lambda's execution role ARN (not the Lambda ARN itself). Specify the exact ARN rather than the entire account to follow least-privilege:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:role/mcp-server-execution-role"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "acme-corp-tenant-7f3a9b"
        }
      }
    }
  ]
}

// The MCP server account: 111122223333
// The target (customer) account: 444455556666 (where this trust policy lives)
// The execution role must also have permission to call sts:AssumeRole:
// { "Action": "sts:AssumeRole", "Resource": "arn:aws:iam::444455556666:role/mcp-tool-role" }

When the MCP server runs on EC2 or ECS rather than Lambda, use the instance profile role or task role ARN as the principal. For EKS with IRSA (IAM Roles for Service Accounts), the principal is a federated OIDC identity — see the session policy page for the IRSA trust policy pattern.

ExternalId: confused-deputy protection for SaaS integrations

The confused deputy attack occurs when a malicious AWS customer tricks a SaaS provider (like an MCP platform) into using its own trusted role to access a victim's account. The attacker sets up a role in their own account with a trust policy pointing at the SaaS provider's account, then socially engineers the provider into assuming the role on their behalf — gaining access to resources the provider was authorized to manage for other customers.

The fix is ExternalId: a secret shared between the SaaS provider and the specific customer, embedded in the trust policy Condition block. The SaaS provider passes this secret in every AssumeRole call. An attacker who sets up a malicious role cannot replicate the correct ExternalId for a victim's account.

// SaaS provider (MCP server) calls AssumeRole with ExternalId
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";

const sts = new STSClient({ region: "us-east-1" });

async function assumeCustomerRole(customerAccountId: string, customerId: string) {
  // ExternalId is stored in the MCP server's own database, not in IAM
  const externalId = await getExternalIdForCustomer(customerId);

  const result = await sts.send(new AssumeRoleCommand({
    RoleArn: `arn:aws:iam::${customerAccountId}:role/mcp-integration-role`,
    RoleSessionName: `mcp-session-${customerId}-${Date.now()}`,
    ExternalId: externalId,          // REQUIRED — trust policy enforces this
    DurationSeconds: 3600,           // 1 hour; see chaining limits below
  }));

  return result.Credentials;
}

// Rules for ExternalId:
// - Generate one per customer onboarding, store in your database
// - Do NOT let the customer choose their own ExternalId (they could reuse another's)
// - Rotate ExternalId only as part of a coordinated re-onboarding (requires trust policy update)
// - ExternalId is NOT a secret if the customer can read their own trust policy — its
//   value is in their account. The protection is that you REQUIRE it, not that it's hidden.

One subtle requirement: the ExternalId value must be set in the trust policy Condition as a StringEquals — not StringLike. A wildcard match defeats the protection entirely since an attacker can craft a value that matches the pattern.

Session duration and role chaining arithmetic

Each AssumeRole call creates a role session with a configurable duration (15 minutes to 12 hours, bounded by the role's MaxSessionDuration setting). The credentials returned have an Expiration timestamp; any API call made after that timestamp returns ExpiredTokenException.

Role chaining occurs when a role session calls AssumeRole again to assume another role (A → B → C). AWS enforces a critical constraint: chained sessions have a hard maximum duration of 1 hour regardless of the DurationSeconds parameter or the target role's MaxSessionDuration. This catches teams by surprise when they try to assume a role with 12-hour MaxSessionDuration from a Lambda that itself was invoked via an assumed role — they get 1-hour credentials even though they requested 12 hours.

// Role chaining duration constraint
// Chain: Lambda execution role → cross-account role A → cross-account role B
//
// Step 1: Lambda execution role (attached directly, not assumed) — full MaxSessionDuration applies
// Step 2: AssumeRole to cross-account role A — DurationSeconds up to role A's MaxSessionDuration
// Step 3: AssumeRole from role A session to role B — MAXIMUM 1 HOUR, always
//
// Detection: check AssumeRoleResponse.Credentials.Expiration
// If Expiration is exactly 1 hour from now despite requesting more, you're chaining.

// Credential refresh strategy: refresh at 80% of lifetime
function credentialManager(credentials) {
  const expiresAt = new Date(credentials.Expiration).getTime();
  const now = Date.now();
  const lifetimeMs = expiresAt - now;
  const refreshAt = now + lifetimeMs * 0.8;  // refresh at 80% elapsed

  setTimeout(async () => {
    const fresh = await assumeCustomerRole(accountId, customerId);
    updateCredentialCache(fresh);
  }, refreshAt - Date.now());
}

// For high-throughput MCP servers: use the AWS SDK's built-in
// AssumeRoleProvider which handles refresh automatically:
import { fromTemporaryCredentials } from "@aws-sdk/credential-providers";

const credentials = fromTemporaryCredentials({
  params: {
    RoleArn: "arn:aws:iam::444455556666:role/mcp-tool-role",
    RoleSessionName: "mcp-tool-session",
    ExternalId: externalId,
    DurationSeconds: 3600,
  },
  // SDK refreshes automatically before expiry
});

The RoleSessionName appears in CloudTrail logs and is invaluable for tracing which tenant or tool call triggered each API action. Use a naming convention that encodes customerId + tool name + timestamp: mcp-${customerId}-${toolName}-${timestamp}. CloudTrail retains session names in the userIdentity.sessionContext.sessionIssuer field.

Trust policy conditions for tighter scoping

Beyond ExternalId, IAM trust policy conditions let you restrict AssumeRole calls based on MFA, source IP, requested session duration, and more. For MCP server deployments in AWS Organizations, the most useful conditions are:

// Trust policy with multiple conditions
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:role/mcp-server-execution-role"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        // Only allow from principals in the same AWS Organization
        "StringEquals": {
          "aws:PrincipalOrgID": "o-exampleorgid11",
          "sts:ExternalId": "tenant-secret-value"
        },
        // Prevent assuming this role with session duration > 1 hour
        "NumericLessThanEquals": {
          "sts:DurationSeconds": "3600"
        }
      }
    }
  ]
}

// aws:PrincipalOrgID condition key requires the calling principal to belong
// to the specified AWS Organization. Useful when the MCP server and target
// accounts are all within the same org but you still want explicit trust.

// sts:DurationSeconds condition: reject AssumeRole calls requesting
// more than 1 hour. Useful for compliance requirements that mandate
// short-lived credentials for privileged operations.

The aws:SourceAccount condition key is commonly misused in trust policies — it is only valid for service principals (like lambda.amazonaws.com), not for IAM role principals. For IAM principals, use aws:PrincipalAccount instead.

SCPs and permission boundaries: the invisible ceiling

Service Control Policies (SCPs) are organization-level guardrails that apply to every IAM action in a member account. Even if a role's permission policy grants s3:*, an SCP that denies s3:DeleteBucket silently blocks deletions. SCPs do not appear in the role's policy — they exist at the Organizations level and are invisible from within the target account.

Before deploying an MCP tool that relies on an assumed role, validate the effective permissions using the IAM policy simulator with SCPs included:

import { IAMClient, SimulatePrincipalPolicyCommand } from "@aws-sdk/client-iam";
import { OrganizationsClient, ListPoliciesForTargetCommand } from "@aws-sdk/client-organizations";

// Check effective permissions for an assumed role including SCPs
async function simulateWithScps(roleArn: string, actions: string[], resources: string[]) {
  const iam = new IAMClient({ region: "us-east-1" });

  // Fetch SCPs applicable to the target account
  const org = new OrganizationsClient({ region: "us-east-1" });
  const scpResult = await org.send(new ListPoliciesForTargetCommand({
    TargetId: targetAccountId,
    Filter: "SERVICE_CONTROL_POLICY"
  }));

  const result = await iam.send(new SimulatePrincipalPolicyCommand({
    PolicySourceArn: roleArn,
    ActionNames: actions,
    ResourceArns: resources,
    // Include SCPs as policy input documents for the simulation
    PolicyInputList: scpResult.Policies?.map(p => p.Content ?? "") ?? []
  }));

  // EvaluationResults[].EvalDecision: "allowed" | "explicitDeny" | "implicitDeny"
  return result.EvaluationResults;
}

// Practical rule: ALWAYS simulate with SCPs for privileged tool calls
// (IAM mutations, KMS key management, S3 cross-account operations)
// before adding them to production MCP tools

Failure modes reference

FailureSymptomFix
Missing ExternalId on SaaS roleAssumeRole succeeds for any caller with sts:AssumeRole on the role — confused deputy riskAdd StringEquals ExternalId condition to trust policy; generate one UUID per customer at onboarding
Role chaining caps credentials at 1 hourDurationSeconds ignored; Expiration is exactly 1h from now despite requesting 12hIdentify and flatten the chain: call AssumeRole directly from the original non-assumed identity; or refresh credentials at 80% of lifetime
SCP silently denies actionIAM policy allows action but API call returns AccessDenied with no explanation of whyRun SimulatePrincipalPolicy including SCP content; check CloudTrail for scp context in the denial event
aws:SourceAccount condition on IAM principalAssumeRole always denied even from correct principalReplace aws:SourceAccount with aws:PrincipalAccount — SourceAccount is only valid for service principals
RoleSessionName contains invalid charactersAssumeRole returns ValidationError before credentials are issuedRoleSessionName must match regex [\w+=,.@-]{2,64} — no spaces, slashes, or colons; use hyphens as separators
Credentials expired mid-tool-callAWS SDK throws ExpiredTokenException partway through a long-running S3 multipart upload or DynamoDB batch writeUse fromTemporaryCredentials provider which auto-refreshes; for manual management, refresh at 80% of lifetime not at expiry
MaxSessionDuration not set on target roleDurationSeconds > 3600 returns ValidationError even for non-chained callsSet MaxSessionDuration to desired max (up to 43200 = 12h) on the target role via IAM UpdateRole API