Guide · AWS IAM & STS

MCP Server IAM Session Policies — scoped-down credentials for tenant isolation

A multi-tenant MCP server running a single execution role faces a scoping problem: when executing a tool call for tenant A, how do you ensure the resulting AWS operations cannot access tenant B's data? Session policies solve this by attaching an inline policy document to an AssumeRole or GetSessionToken call that further restricts what the resulting session can do — without modifying the base role. The resulting session's effective permissions are the intersection of the base role's policies, any permission boundary, and the session policy. This means a session policy can never grant permissions the base role doesn't have, but it can narrow a broad base role down to the exact actions and resources needed for a single tenant's tool call. For MCP servers, this is the foundation of fine-grained tenant isolation without managing hundreds of per-tenant IAM roles.

TL;DR

Pass a Policy JSON string in the AssumeRole call to scope the resulting session. Effective permissions = role policy ∩ session policy (∩ permission boundary ∩ SCPs). Session policies never grant — they only restrict. The JSON-encoded session policy must be under 2,048 characters for inline, or reference up to 10 managed policies (each up to 6,144 characters). For dynamic per-tenant scoping, use IAM condition keys with resource tags (aws:ResourceTag) or path-based resource ARN conditions rather than enumerating resources in the session policy.

Session policy basics: passing inline policy with AssumeRole

An inline session policy is a JSON string passed as the Policy parameter to AssumeRole, AssumeRoleWithWebIdentity, AssumeRoleWithSAML, or GetSessionToken. It narrows what the resulting temporary credentials can do:

import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";

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

async function getTenantscopedCredentials(tenantId: string) {
  // Session policy: restrict the base role to only the tenant's S3 prefix and DynamoDB partition
  const sessionPolicy = JSON.stringify({
    Version: "2012-10-17",
    Statement: [
      {
        Effect: "Allow",
        Action: ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
        Resource: [
          // Only tenant's prefix in the shared bucket
          `arn:aws:s3:::mcp-data-bucket/tenants/${tenantId}/*`,
          // ListBucket requires the bucket ARN (not the prefix ARN)
          "arn:aws:s3:::mcp-data-bucket"
        ],
        Condition: {
          // s3:prefix condition limits ListBucket to the tenant's prefix
          "StringLike": {
            "s3:prefix": [`tenants/${tenantId}/*`]
          }
        }
      },
      {
        Effect: "Allow",
        Action: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:Query"],
        Resource: "arn:aws:dynamodb:us-east-1:111122223333:table/mcp-tools",
        Condition: {
          // LeadingKeys restricts DynamoDB access to items where the partition key
          // starts with the tenant ID — enforces per-tenant data isolation
          "ForAllValues:StringLike": {
            "dynamodb:LeadingKeys": [`${tenantId}#*`]
          }
        }
      }
    ]
  });

  // Session policy must be under 2,048 characters when JSON-encoded
  // For larger policies, use PolicyArns to reference managed policies instead
  if (sessionPolicy.length > 2048) {
    throw new Error(`Session policy too large: ${sessionPolicy.length} characters`);
  }

  const result = await sts.send(new AssumeRoleCommand({
    RoleArn: "arn:aws:iam::111122223333:role/mcp-multi-tenant-execution-role",
    RoleSessionName: `mcp-tenant-${tenantId}-${Date.now()}`,
    Policy: sessionPolicy,
    DurationSeconds: 900,  // 15 minutes — short-lived tool call credential
  }));

  return result.Credentials;
}

The session name RoleSessionName appears in CloudTrail as the sessionIssuer.userName and in the assumed principal ARN as the final segment. Using a tenant ID in the session name makes per-tenant activity trivially queryable in CloudTrail Insights.

Permissions intersection in practice

The intersection model means session policies can only remove permissions, never add them. This is the key safety property for multi-tenant MCP servers:

// Example permissions intersection
//
// Base role policy (attached to mcp-multi-tenant-execution-role):
//   Allow: s3:*, dynamodb:*, sqs:SendMessage, cloudwatch:PutMetricData
//
// Session policy (passed in AssumeRole call):
//   Allow: s3:GetObject, s3:PutObject on tenants/acme/* only
//          dynamodb:GetItem, dynamodb:Query on mcp-tools table, acme# keys only
//
// Effective session permissions (intersection):
//   s3:GetObject on tenants/acme/*   ← in BOTH base role AND session policy
//   s3:PutObject on tenants/acme/*   ← in BOTH
//   dynamodb:GetItem, dynamodb:Query ← scoped to acme# keys
//
// BLOCKED by session policy (despite base role allowing):
//   s3:DeleteObject                  ← not in session policy
//   sqs:SendMessage                  ← not in session policy
//   cloudwatch:PutMetricData         ← not in session policy
//   s3:GetObject on tenants/other/*  ← resource condition blocks it
//
// A compromised tenant tool call cannot escalate to other tenants' data
// because the session policy scope prevents it

// CANNOT grant via session policy (even if you try):
//   Session policy Allow: iam:CreateRole → DENIED (not in base role)
//   Session policy Allow: s3:* on * → same as base role, no additional grant

The intersection model is what makes session policies safe for multi-tenancy: even if a tenant can inject arbitrary values into the tool call parameters (path traversal, SSRF attempts), the session policy's resource ARN conditions enforce the scoping at the IAM layer — before the API call even reaches S3 or DynamoDB.

Tag-based session policies with aws:ResourceTag

Enumerating resource ARNs in session policies is inflexible — every time a tenant provisions new resources, the session policy generation logic needs updating. A more scalable approach is tag-based access control: tag all tenant resources with a TenantId tag, and use aws:ResourceTag conditions in the session policy:

// Tag all tenant resources at creation time:
// S3: not supported for object-level tag conditions in session policies
// EC2, ECS, Lambda, DynamoDB tables, SQS queues: supported

// Session policy using tag-based conditions
const tagBasedSessionPolicy = JSON.stringify({
  Version: "2012-10-17",
  Statement: [
    {
      Effect: "Allow",
      Action: [
        "lambda:InvokeFunction",
        "lambda:GetFunction"
      ],
      Resource: "arn:aws:lambda:us-east-1:111122223333:function:*",
      Condition: {
        // Only allow invoking Lambda functions tagged with TenantId = tenantId
        "StringEquals": {
          "aws:ResourceTag/TenantId": tenantId
        }
      }
    },
    {
      Effect: "Allow",
      Action: ["sqs:SendMessage", "sqs:ReceiveMessage"],
      Resource: "arn:aws:sqs:us-east-1:111122223333:*",
      Condition: {
        "StringEquals": {
          "aws:ResourceTag/TenantId": tenantId
        }
      }
    }
  ]
});

// Note: aws:ResourceTag conditions require the resource to support resource-level
// tagging AND the IAM action to be taggable at the resource level.
// Check IAM Authorization Reference for which actions support aws:ResourceTag.

// For S3 objects (which don't support aws:ResourceTag in session policies),
// use aws:ResourceARN conditions or prefix-based resource ARN constraints instead.

Session policy size limits and managed policy alternatives

Inline session policies have a 2,048 character limit (JSON-encoded). For complex multi-service tenant isolation policies that exceed this limit, use PolicyArns to reference pre-created managed policies:

// PolicyArns approach for larger session restrictions
// Create managed policies once per tenant tier (not per tenant)
// and reference them in AssumeRole calls

// Option 1: PolicyArns (reference existing managed policies, up to 10)
const result = await sts.send(new AssumeRoleCommand({
  RoleArn: "arn:aws:iam::111122223333:role/mcp-execution-role",
  RoleSessionName: `mcp-${tenantId}`,
  PolicyArns: [
    // Pre-created managed policy for standard tenant access tier
    { arn: "arn:aws:iam::111122223333:policy/mcp-standard-tenant-session" },
    // Optional: tier-specific additional restrictions
    { arn: `arn:aws:iam::111122223333:policy/mcp-tier-${tenantTier}-session` }
  ],
  // Can combine PolicyArns (up to 10) with an inline Policy
  // Both are intersected: effective = role policy ∩ PolicyArns[0] ∩ PolicyArns[1] ∩ inline Policy
}));

// Option 2: Use IAM tags on the session via Tags parameter
// Tags are accessible as aws:PrincipalTag in policies during the session
const taggedResult = await sts.send(new AssumeRoleCommand({
  RoleArn: "arn:aws:iam::111122223333:role/mcp-execution-role",
  RoleSessionName: `mcp-${tenantId}`,
  Tags: [
    { Key: "TenantId", Value: tenantId },
    { Key: "TenantTier", Value: tenantTier }
  ],
  // The role's permission policy can use ${aws:PrincipalTag/TenantId}
  // as a policy variable to dynamically scope resource access:
  // "Resource": "arn:aws:s3:::mcp-data/${aws:PrincipalTag/TenantId}/*"
  // This allows a static role policy to enforce per-tenant scoping
  // without per-call session policy generation
}));

// Tags approach size limit: 50 session tags, key ≤128 chars, value ≤256 chars
// Policy variable approach: the role's permission policy is static and uses
// ${aws:PrincipalTag/TenantId} — evaluates to the session tag value at request time

The policy variable approach (${aws:PrincipalTag/TenantId} in the base role's permission policy) is often cleaner than generating per-tenant session policies: the base role's static policy does the scoping, and the MCP server only needs to set the correct tags on the session. This also avoids the 2,048-character session policy limit entirely.

Failure modes reference

FailureSymptomFix
Session policy exceeds 2,048 character limitAssumeRole returns ValidationError: Session policy document is too largeMove to PolicyArns referencing managed policies; or use policy variables (${aws:PrincipalTag}) in the base role policy to avoid per-call policy generation
Session policy grants permissions not in base roleSession policy allows s3:DeleteBucket but base role does not; API returns AccessDeniedExpected behavior — session policies cannot grant beyond the base role; audit the base role policy to add needed permissions there
dynamodb:LeadingKeys condition wrong formatDynamoDB query returns empty results or AccessDenied for valid keysLeadingKeys condition must match the partition key value exactly (not a path prefix); use ForAllValues:StringEquals for exact match or ForAllValues:StringLike for prefix matching
Tag-based condition not evaluatingSession policy with aws:ResourceTag condition allows access to all resourcesVerify the resource is tagged at creation time; check that the IAM action supports aws:ResourceTag (not all actions do); use IAM condition key reference documentation
Policy variable not resolving${aws:PrincipalTag/TenantId} in resource ARN evaluates to literal stringTags must be passed in the Tags parameter of AssumeRole; verify tag key matches exactly (case-sensitive); confirm policy variable syntax: ${aws:PrincipalTag/KeyName}
s3:ListBucket returns all keys despite prefix conditionListing the shared bucket returns objects from all tenants despite s3:prefix session conditionThe s3:prefix condition must be in the session policy AND the ListBucket call must include the Prefix query parameter; condition alone does not filter results — it only permits or denies the call