Guide · AWS IAM

MCP Server IAM Resource-Based Policies — S3, Lambda, SQS cross-account access

Resource-based policies are attached directly to AWS resources (S3 buckets, Lambda functions, SQS queues, KMS keys) and specify which principals can perform which actions on that resource — including principals from other AWS accounts. For cross-account access, resource-based policies offer an alternative to the AssumeRole pattern: the resource owner grants access directly to the MCP server's role, and the role can act on the resource without assuming an intermediate role in the target account. Understanding when to use resource-based policies versus role assumption — and how they interact with identity-based policies for same-account vs. cross-account access — is essential for building MCP tools that access customer resources without over-provisioning permissions.

TL;DR

For cross-account access, both a resource-based policy grant AND an identity-based policy on the caller are required — the resource must allow the principal, and the principal's own policy must allow the action on the resource. For same-account access, either a resource policy OR an identity policy alone is sufficient. Lambda resource policies are set via AddPermission (not PutPolicy); SQS queue policies must include aws:SourceAccount when the principal is a service like SNS or EventBridge. Always use aws:PrincipalAccount condition (not aws:SourceAccount) when restricting by caller account on resource policies.

Same-account vs cross-account authorization logic

The authorization evaluation differs based on whether the caller and resource are in the same account:

// SAME ACCOUNT authorization:
// Access is granted if EITHER the identity policy OR the resource policy allows it.
// Example: IAM role in account A accessing S3 bucket in account A
//
//   Identity policy allows s3:GetObject: YES → ACCESS GRANTED
//   (even if bucket policy is silent or doesn't mention the role)
//
//   Bucket policy allows s3:GetObject for the role: YES → ACCESS GRANTED
//   (even if identity policy has no s3 permissions)
//
// CROSS ACCOUNT authorization:
// Access is granted only if BOTH the identity policy AND the resource policy allow it.
// Example: IAM role in account A accessing S3 bucket in account B
//
//   Both required:
//   1. Bucket policy in account B allows s3:GetObject for arn:aws:iam::AccountA:role/mcp-role
//   2. Identity policy in account A allows s3:GetObject on the account B bucket ARN
//
//   If either is missing: ACCESS DENIED (even if one side grants it)

// Role assumption bypasses this: if account A assumes a role in account B,
// the resulting session is "in account B" and only the role policy matters
// for same-account resource access. Cross-account evaluation only applies
// when acting as an account A principal on account B resources directly.

This distinction matters for MCP tool design: for accessing customer S3 buckets, you can either (a) have the customer set a bucket policy allowing your role, and update your role policy to allow access to their bucket, or (b) have the customer create a role you can assume. The bucket policy approach is simpler but creates a tighter coupling — the customer must update the bucket policy every time your MCP server's role ARN changes.

S3 bucket policies for cross-account MCP tool access

The S3 bucket policy approach is common for read-only data access tools — simpler to set up than role assumption, and easy to audit by examining the bucket policy. The critical rule for cross-account S3 bucket policies: always include an aws:PrincipalAccount condition to prevent confused-deputy attacks where a malicious actor guesses your role ARN format:

// Customer's S3 bucket policy granting MCP server cross-account read access
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowMCPServerRead",
      "Effect": "Allow",
      "Principal": {
        // Exact role ARN — more secure than account-level grant
        "AWS": "arn:aws:iam::111122223333:role/mcp-server-execution-role"
      },
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::customer-data-bucket",
        "arn:aws:s3:::customer-data-bucket/*"
      ],
      "Condition": {
        // Belt-and-suspenders: confirm the caller is from the expected account
        // Prevents cases where the role ARN was spoofed or the role was deleted
        // and a new role in a different account happened to get the same path/name
        "StringEquals": {
          "aws:PrincipalAccount": "111122223333"
        }
      }
    }
  ]
}

// MCP server's identity policy — required for cross-account access:
{
  "Effect": "Allow",
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::customer-data-bucket",
    "arn:aws:s3:::customer-data-bucket/*"
  ]
}

// S3 object ownership note:
// If the MCP server writes objects to a customer bucket (PutObject),
// the customer account does NOT own the object by default — the MCP server's
// account owns it. Set bucket ObjectOwnership to BucketOwnerPreferred
// and include x-amz-acl:bucket-owner-full-control header in PutObject calls,
// or use bucket-owner-enforced ownership mode (blocks ACLs entirely, auto-grants).

Lambda resource policies: AddPermission vs PutResourcePolicy

Lambda resource policies (function policies) control which principals can invoke a Lambda function. Unlike S3 bucket policies, Lambda function policies are managed through the AddPermission API (not a generic PutPolicy call), which creates individual permission statements:

import { LambdaClient, AddPermissionCommand, GetPolicyCommand } from "@aws-sdk/client-lambda";

const lambda = new LambdaClient({ region: "us-east-1" });

// Grant cross-account MCP server permission to invoke a Lambda tool
await lambda.send(new AddPermissionCommand({
  FunctionName: "customer-tool-handler",
  StatementId: "allow-mcp-server-invoke",   // unique ID for this statement
  Action: "lambda:InvokeFunction",
  Principal: "arn:aws:iam::111122223333:role/mcp-server-execution-role",
  // Optional: restrict to a specific source account (for service principals)
  // For IAM role principals, aws:SourceAccount is NOT needed — the role ARN
  // already scopes to a specific account
}));

// Read the current function policy
const policy = await lambda.send(new GetPolicyCommand({
  FunctionName: "customer-tool-handler"
}));
// policy.Policy is a JSON string — parse it to read statements

// Lambda resource policy for EventBridge rule invocation (service principal):
await lambda.send(new AddPermissionCommand({
  FunctionName: "mcp-event-handler",
  StatementId: "allow-eventbridge",
  Action: "lambda:InvokeFunction",
  Principal: "events.amazonaws.com",
  SourceArn: "arn:aws:events:us-east-1:111122223333:rule/mcp-tool-trigger",
  // SourceArn scopes to a specific EventBridge rule — without this,
  // any EventBridge rule in the account (or any account!) can invoke
  // the function if they can reference its ARN
}));

// For Lambda URL invocations (AuthType: AWS_IAM):
// The function URL uses the resource policy, not a separate IAM mechanism
// Grant lambda:InvokeFunctionUrl (different action from lambda:InvokeFunction)
await lambda.send(new AddPermissionCommand({
  FunctionName: "mcp-tool-url-handler",
  StatementId: "allow-url-invoke",
  Action: "lambda:InvokeFunctionUrl",
  Principal: "arn:aws:iam::111122223333:role/mcp-client-role",
  FunctionUrlAuthType: "AWS_IAM"
}));

Lambda function policies are region-scoped: a policy on a function in us-east-1 does not apply to the same function name in us-west-2. When an MCP server manages Lambda tools across multiple regions, each region's function needs its own resource policy statement.

SQS queue policies for cross-account tool messaging

SQS queue policies follow the same resource-based policy model but have two important constraints: the aws:SourceAccount condition is required when the principal is a service (SNS, EventBridge, Lambda) to prevent the confused-deputy attack, and queue policies have a size limit of 20 KB — large policies with many tenant grants can hit this limit.

// SQS queue policy for cross-account MCP tool messages
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowMCPServerSendMessage",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:role/mcp-server-execution-role"
      },
      "Action": ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"],
      "Resource": "arn:aws:sqs:us-east-1:444455556666:mcp-tool-queue"
    },
    {
      // Allow SNS topic to send to this queue (service principal requires SourceArn/SourceAccount)
      "Sid": "AllowSNSPublish",
      "Effect": "Allow",
      "Principal": { "Service": "sns.amazonaws.com" },
      "Action": "sqs:SendMessage",
      "Resource": "arn:aws:sqs:us-east-1:444455556666:mcp-tool-queue",
      "Condition": {
        "ArnEquals": {
          // Scope to a specific SNS topic — without this, any SNS topic
          // can send to this queue if it knows the queue ARN
          "aws:SourceArn": "arn:aws:sns:us-east-1:444455556666:mcp-tool-notifications"
        },
        "StringEquals": {
          // Belt-and-suspenders: confirm source account
          "aws:SourceAccount": "444455556666"
        }
      }
    }
  ]
}

// For high-tenant-count MCP servers, avoid per-tenant queue policies.
// Instead: use one queue with a VPC endpoint policy, or use one queue per
// tenant (each with a simple policy), or use IAM role assumption with the
// MCP server assuming a per-tenant role that has SQS permissions via
// identity policy rather than queue resource policy.

KMS key policies for cross-account encryption

KMS key policies are required for cross-account key usage — you cannot grant cross-account KMS access through identity policies alone. The key policy must explicitly allow the external account or principal:

// KMS key policy granting cross-account MCP server access to decrypt
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowKeyAdministration",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::444455556666:root" },
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "AllowMCPServerDecrypt",
      "Effect": "Allow",
      "Principal": {
        // Grant to the MCP server's execution role in the other account
        "AWS": "arn:aws:iam::111122223333:role/mcp-server-execution-role"
      },
      "Action": ["kms:Decrypt", "kms:DescribeKey"],
      "Resource": "*",
      "Condition": {
        // Optional: require the caller to be a specific VPC endpoint
        // "StringEquals": { "kms:ViaService": "s3.us-east-1.amazonaws.com" }
        // This means the key can only be used via S3 operations, not directly
      }
    }
  ]
}

// CRITICAL: the "Allow root" statement is mandatory in every KMS key policy
// If this statement is removed, the key becomes unmanageable — IAM policies
// in the owning account can no longer grant or restrict KMS key access
// (AWS must be contacted for recovery)

// MCP server's identity policy also needs explicit kms:Decrypt permission:
{
  "Effect": "Allow",
  "Action": ["kms:Decrypt", "kms:DescribeKey"],
  "Resource": "arn:aws:kms:us-east-1:444455556666:key/mrk-examplekeyid"
}

Failure modes reference

FailureSymptomFix
Cross-account missing identity policyS3 bucket policy allows the role, but GetObject returns AccessDeniedBoth resource policy (bucket) AND identity policy (role) must allow the action for cross-account access; add s3:GetObject on the bucket ARN to the role's policy
S3 object ownership mismatchMCP server writes objects to customer bucket, customer cannot read themEnable BucketOwnerPreferred on the bucket and include x-amz-acl:bucket-owner-full-control in PutObject, or set bucket ownership to BucketOwnerEnforced
Lambda policy not scoped to function ARNAddPermission call succeeds but invocation from another account still returns AccessDeniedConfirm the function name in AddPermission matches exactly — alias or version qualifiers are separate resources; check that the caller is using the unqualified function ARN
SQS confused deputy (service principal)Any SNS topic or EventBridge rule can send to the SQS queue by knowing its ARNAlways set aws:SourceArn condition when granting to a service principal in SQS queue policies
KMS key policy missing root statementKey becomes unmanageable; all KMS API calls return AccessDenied even for the owning accountThe root Allow statement cannot be removed; if missing, contact AWS Support — there is no self-service recovery path
Lambda URL AuthType confusionlambda:InvokeFunction policy allows the caller but Lambda URL invocation returns 403Lambda URL requires lambda:InvokeFunctionUrl permission in the resource policy, not lambda:InvokeFunction — these are distinct actions