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
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:
- 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
DurationSecondsor the target role'sMaxSessionDuration. Detect this by comparing the returnedExpirationto what you requested. Flatten the chain if longer sessions are needed. - RoleSessionName has a strict character set. Only
[\w+=,.@-]{2,64}— no slashes, no spaces, no colons. Use hyphens as separators in thecustomerId-toolName-timestamppattern. - 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
AccessDeniedwith no indication that an SCP caused it. RunSimulatePrincipalPolicywith 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:
| Criterion | Resource Policy | Role Assumption |
|---|---|---|
| Setup complexity | Low — customer sets bucket/queue policy | Medium — customer creates IAM role with trust policy |
| Credential handling | None — MCP server uses its own execution role | Required — manage per-customer STS credentials |
| Resource scope | Per-resource (each resource needs a policy) | Account-wide (role policy scopes all resources) |
| Audit trail | MCP server account in CloudTrail | Customer account in CloudTrail via session name |
| ExternalId protection | None (use aws:PrincipalAccount condition instead) | ExternalId in trust policy |
| IAM writes (create roles, modify policies) | Cannot — bucket policies don't grant IAM access | Yes — assumed role can have iam:* if needed |
| Many resources per tenant | Policy per resource — update burden | Single 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)
| Pattern | Failure | Symptom | Fix |
|---|---|---|---|
| Role assumption | ExternalId missing | Confused deputy attack possible — any account can assume the role | Add StringEquals sts:ExternalId condition to trust policy; generate per-customer at onboarding |
| Role assumption | Role chaining 1-hour cap | Requested 12h credentials, received 1h | Flatten the chain or implement 80% lifetime refresh |
| Role assumption | SCP silent denial | AccessDenied with no hint of cause | Run SimulatePrincipalPolicy including SCP content |
| STS credentials | ExpiredTokenException mid-call | Long tool call fails after 60 minutes | Use fromTemporaryCredentials or refresh at 80% of lifetime |
| STS credentials | IRSA token file not mounted | Pod uses node role instead of service account role | Check service account annotation and projected volume mount |
| Permission boundaries | Boundary not attached at creation | Created role has no ceiling — can be granted admin | Use iam:PermissionsBoundary condition on iam:CreateRole; boundary must be in the CreateRole call, not added after |
| Permission boundaries | Boundary detached post-creation | iam:DeleteRolePermissionsBoundary removes ceiling | Add Deny on iam:DeleteRolePermissionsBoundary for mcp-created-* role prefix |
| Resource policies | Cross-account missing identity policy | Bucket policy allows role but access still denied | Cross-account requires both resource policy AND identity policy; add S3 permissions to role policy |
| Resource policies | Lambda URL wrong action | InvokeFunction policy set, URL invocation returns 403 | Lambda URL requires lambda:InvokeFunctionUrl, not lambda:InvokeFunction |
| Session policies | Policy exceeds 2,048 chars | AssumeRole ValidationError on complex tenant policy | Use PolicyArns or switch to policy variable approach with session tags |
| Session policies | dynamodb:LeadingKeys format wrong | DynamoDB access fails for valid keys | LeadingKeys must match partition key value; use ForAllValues:StringLike for prefix matching |
| Session policies | Policy variable not resolving | ${aws:PrincipalTag/TenantId} evaluates as literal string | Tags must be passed in Tags[] parameter of AssumeRole, not in session policy body |
Pattern selection guide
| MCP tool scenario | Recommended pattern | Why |
|---|---|---|
| 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 customers | Permission boundaries (Pattern 3) | Prevents privilege escalation via created roles; iam:PermissionsBoundary condition enforces ceiling at creation time |
| Multi-tenant tool calls within single account | Session policies (Pattern 5) | No per-tenant role needed; session policy intersection enforces isolation at IAM layer |
| EKS-hosted MCP server needing AWS access | IRSA / AssumeRoleWithWebIdentity (Pattern 2) | No credential management; OIDC token auto-rotated by kubelet; scoped to specific service account |
| High-tenant-count, complex per-tenant scoping | Session 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 infrastructure | OIDC AssumeRoleWithWebIdentity (Pattern 2) | No long-lived keys; branch-scoped trust policy; token issued per workflow run |