Guide · MCP AWS IAM Integration
MCP Server AWS IAM — assume roles, simulate policies, and detect privilege escalation
AWS Identity and Access Management (IAM) is the authorization backbone of every AWS-based architecture. This guide covers building TypeScript MCP tools that interact with the IAM and STS APIs: assuming cross-account roles via sts:AssumeRole, listing and filtering policies, simulating principal permissions with iam:SimulatePrincipalPolicy, creating access keys with confirm guards, detecting privilege escalation paths in policy documents, and wiring a /health/iam endpoint so AliveMCP detects credential expiry and permission revocations before your agent workflows silently fail.
TL;DR
Use the AWS SDK v3 with explicit credential providers — never hardcode keys. sts:AssumeRole is the canonical cross-account access pattern; cache the returned credentials until Expiration minus 60 seconds. For policy analysis, use iam:SimulatePrincipalPolicy — it's the authoritative source of truth for effective permissions, accounting for SCPs, permission boundaries, and resource-level conditions. Never build your own policy parser. Privilege escalation detection focuses on the three dangerous combinations: iam:CreateRole + iam:AttachRolePolicy, iam:CreatePolicyVersion, and iam:PutUserPolicy. Wire AliveMCP to GET /health/iam which calls sts:GetCallerIdentity — if that fails, your credentials are invalid or revoked.
SDK setup and credential providers
The AWS SDK v3 uses modular clients. Install only the services you need — this keeps Lambda deployment packages small and makes tree-shaking effective. Use the fromNodeProviderChain credential provider, which automatically resolves credentials from environment variables, AWS profiles, instance metadata, and ECS task roles in priority order.
import { STSClient, AssumeRoleCommand, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
import { IAMClient, ListPoliciesCommand, SimulatePrincipalPolicyCommand, CreateAccessKeyCommand, GetUserPolicyCommand, ListAttachedUserPoliciesCommand } from '@aws-sdk/client-iam';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
const region = process.env.AWS_REGION ?? 'us-east-1';
const baseConfig = {
region,
credentials: fromNodeProviderChain() // env → profile → EC2 IMDS → ECS
};
const stsClient = new STSClient(baseConfig);
const iamClient = new IAMClient(baseConfig);
// Cache for assumed role credentials, keyed by role ARN
const roleCredentialCache = new Map();
async function getAssumedRoleCredentials(roleArn: string, sessionName: string) {
const cached = roleCredentialCache.get(roleArn);
// Renew 60 seconds before expiry
if (cached && cached.expiresAt.getTime() - Date.now() > 60_000) {
return cached;
}
const res = await stsClient.send(new AssumeRoleCommand({
RoleArn: roleArn,
RoleSessionName: sessionName,
DurationSeconds: 3600 // 1 hour; max depends on role's MaxSessionDuration
}));
const creds = {
accessKeyId: res.Credentials!.AccessKeyId!,
secretAccessKey: res.Credentials!.SecretAccessKey!,
sessionToken: res.Credentials!.SessionToken!,
expiresAt: res.Credentials!.Expiration!
};
roleCredentialCache.set(roleArn, creds);
return creds;
}
| Credential source | Priority | Use case |
|---|---|---|
AWS_ACCESS_KEY_ID env var |
Highest | Local dev, CI pipelines |
~/.aws/credentials profile |
Second | Developer workstations |
| EC2 instance metadata (IMDS) | Third | EC2-hosted MCP servers |
| ECS task role | Fourth | Containerized deployments |
| EKS pod identity | Fifth | Kubernetes workloads |
assume_role tool
The assume_role MCP tool lets agents temporarily adopt a different IAM identity — typically a cross-account role or a role with elevated permissions for a specific task. The key constraint is that the calling principal must have sts:AssumeRole permission on the target role, and the target role's trust policy must allow the calling principal.
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
server.tool(
'assume_role',
{
role_arn: z.string().regex(/^arn:aws:iam::\d{12}:role\/.+/, 'Must be a valid IAM role ARN'),
session_name: z.string().min(2).max(64).default('mcp-session'),
duration_seconds: z.number().int().min(900).max(43200).default(3600)
},
async ({ role_arn, session_name, duration_seconds }) => {
try {
const res = await stsClient.send(new AssumeRoleCommand({
RoleArn: role_arn,
RoleSessionName: session_name,
DurationSeconds: duration_seconds
}));
const creds = res.Credentials!;
return {
content: [{
type: 'text',
text: JSON.stringify({
assumed_role_arn: res.AssumedRoleUser?.Arn,
access_key_id: creds.AccessKeyId,
// Do NOT return the secret key in plaintext — return a masked indicator
secret_access_key_set: true,
session_token_set: true,
expires_at: creds.Expiration?.toISOString(),
assumed_role_id: res.AssumedRoleUser?.AssumedRoleId
}, null, 2)
}]
};
} catch (err: any) {
if (err.name === 'AccessDenied') {
throw new McpError(ErrorCode.InvalidRequest,
`Access denied: the calling principal lacks sts:AssumeRole on ${role_arn}`);
}
throw err;
}
}
);
list_iam_policies and get_effective_permissions tools
IAM has three types of policies: AWS managed (owned by AWS, versioned automatically), customer managed (your own reusable policies), and inline (embedded in a user/role/group). The list_iam_policies tool filters by scope. For effective permissions, always use the policy simulation API — it accounts for all layers: SCPs, permission boundaries, session policies, and resource policies.
server.tool(
'list_iam_policies',
{
scope: z.enum(['All', 'AWS', 'Local']).default('Local'),
path_prefix: z.string().optional(),
max_results: z.number().int().min(1).max(1000).default(50)
},
async ({ scope, path_prefix, max_results }) => {
const policies: Array<{ name: string; arn: string; type: string; updated: string }> = [];
let marker: string | undefined;
do {
const res = await iamClient.send(new ListPoliciesCommand({
Scope: scope,
PathPrefix: path_prefix,
Marker: marker,
MaxItems: Math.min(max_results - policies.length, 100)
}));
for (const p of res.Policies ?? []) {
policies.push({
name: p.PolicyName!,
arn: p.Arn!,
type: p.Arn!.startsWith('arn:aws:iam::aws:') ? 'AWS Managed' : 'Customer Managed',
updated: p.UpdateTime?.toISOString() ?? ''
});
}
marker = res.IsTruncated ? res.Marker : undefined;
} while (marker && policies.length < max_results);
return {
content: [{
type: 'text',
text: JSON.stringify({ count: policies.length, scope, policies }, null, 2)
}]
};
}
);
server.tool(
'get_effective_permissions',
{
principal_arn: z.string().min(1), // IAM user or role ARN
action_names: z.array(z.string()).min(1).max(200),
resource_arns: z.array(z.string()).default(['*'])
},
async ({ principal_arn, action_names, resource_arns }) => {
const results: Array<{ action: string; resource: string; decision: string; reason: string }> = [];
// SimulatePrincipalPolicy handles max 100 actions per call
const chunks = [];
for (let i = 0; i < action_names.length; i += 100) {
chunks.push(action_names.slice(i, i + 100));
}
for (const chunk of chunks) {
const res = await iamClient.send(new SimulatePrincipalPolicyCommand({
PolicySourceArn: principal_arn,
ActionNames: chunk,
ResourceArns: resource_arns
}));
for (const result of res.EvaluationResults ?? []) {
results.push({
action: result.EvalActionName!,
resource: result.EvalResourceName!,
decision: result.EvalDecision!, // 'allowed', 'explicitDeny', 'implicitDeny'
reason: result.EvalDecisionDetails
? JSON.stringify(result.EvalDecisionDetails)
: result.EvalDecision === 'allowed' ? 'allowed by policy' : 'no matching allow statement'
});
}
}
const allowed = results.filter(r => r.decision === 'allowed');
const denied = results.filter(r => r.decision !== 'allowed');
return {
content: [{
type: 'text',
text: JSON.stringify({
principal: principal_arn,
summary: { allowed: allowed.length, denied: denied.length },
results
}, null, 2)
}]
};
}
);
detect_privilege_escalation tool
Privilege escalation in IAM occurs when a principal can combine lower-privilege permissions to grant themselves or others additional access. The three most dangerous patterns are: creating a role and attaching an admin policy (iam:CreateRole + iam:AttachRolePolicy), creating a new policy version with elevated permissions (iam:CreatePolicyVersion), and writing an inline policy directly to a user/role (iam:PutUserPolicy/iam:PutRolePolicy).
const ESCALATION_PATTERNS = [
{
name: 'CreateRole + AttachRolePolicy',
required: ['iam:CreateRole', 'iam:AttachRolePolicy'],
description: 'Can create a new role with an admin policy and assume it'
},
{
name: 'CreatePolicyVersion',
required: ['iam:CreatePolicyVersion'],
description: 'Can overwrite existing policy with a more permissive version'
},
{
name: 'PutUserPolicy / PutRolePolicy',
required: ['iam:PutUserPolicy'],
description: 'Can write a new inline policy directly to a user or role'
},
{
name: 'AddUserToGroup',
required: ['iam:AddUserToGroup'],
description: 'Can add self to a group with admin permissions'
},
{
name: 'PassRole + EC2/Lambda',
required: ['iam:PassRole', 'ec2:RunInstances'],
description: 'Can launch EC2 with an admin role attached'
},
{
name: 'UpdateLoginProfile',
required: ['iam:UpdateLoginProfile'],
description: 'Can change another IAM user password and log in as them'
}
];
server.tool(
'detect_privilege_escalation',
{
principal_arn: z.string().min(1)
},
async ({ principal_arn }) => {
// Collect all IAM actions we need to check
const allActions = [...new Set(ESCALATION_PATTERNS.flatMap(p => p.required))];
const res = await iamClient.send(new SimulatePrincipalPolicyCommand({
PolicySourceArn: principal_arn,
ActionNames: allActions,
ResourceArns: ['*']
}));
const allowedActions = new Set(
(res.EvaluationResults ?? [])
.filter(r => r.EvalDecision === 'allowed')
.map(r => r.EvalActionName!)
);
const risks = ESCALATION_PATTERNS.filter(pattern =>
pattern.required.every(action => allowedActions.has(action))
);
return {
content: [{
type: 'text',
text: JSON.stringify({
principal: principal_arn,
privilege_escalation_risks: risks.length,
risks: risks.map(r => ({ pattern: r.name, description: r.description, required_permissions: r.required })),
all_iam_actions_allowed: [...allowedActions].filter(a => a.startsWith('iam:'))
}, null, 2)
}]
};
}
);
create_access_key tool with confirm guard
server.tool(
'create_access_key',
{
username: z.string().min(1),
confirm: z.literal(true) // caller must pass confirm: true — prevents accidental key creation
},
async ({ username }) => {
// Check existing key count first — IAM allows max 2 keys per user
const existingRes = await iamClient.send(
new ListAccessKeysCommand({ UserName: username })
);
const existing = existingRes.AccessKeyMetadata ?? [];
if (existing.length >= 2) {
throw new McpError(ErrorCode.InvalidRequest,
`User ${username} already has 2 access keys (IAM maximum). Delete one before creating a new key.`);
}
const res = await iamClient.send(new CreateAccessKeyCommand({ UserName: username }));
const key = res.AccessKey!;
return {
content: [{
type: 'text',
text: JSON.stringify({
username: key.UserName,
access_key_id: key.AccessKeyId,
secret_access_key: key.SecretAccessKey, // only shown once at creation
status: key.Status,
created_at: key.CreateDate?.toISOString(),
warning: 'Store the secret_access_key securely — it cannot be retrieved again'
}, null, 2)
}]
};
}
);
The minimum IAM policy needed by an MCP server that only reads IAM data and calls STS:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"iam:ListPolicies",
"iam:ListAttachedUserPolicies",
"iam:ListUserPolicies",
"iam:GetUserPolicy",
"iam:SimulatePrincipalPolicy",
"sts:GetCallerIdentity"
],
"Resource": "*"
}
]
}
Health endpoint: /health/iam
sts:GetCallerIdentity is the canonical IAM health check — it requires no permissions beyond valid credentials. If it succeeds, the credentials are valid and the AWS API is reachable. AliveMCP polls this endpoint and alerts you the moment credentials expire, are deactivated, or the IAM API becomes unreachable.
import express from 'express';
const app = express();
app.get('/health/iam', async (_req, res) => {
const start = Date.now();
try {
const identity = await stsClient.send(new GetCallerIdentityCommand({}));
res.status(200).json({
status: 'ok',
account: identity.Account,
user_id: identity.UserId,
arn: identity.Arn,
latency_ms: Date.now() - start
});
} catch (err: any) {
const status = err.name === 'ExpiredTokenException' ? 401 : 503;
res.status(status).json({
status: 'error',
error_code: err.name,
error: err.message,
latency_ms: Date.now() - start
});
}
});
app.listen(3001);
| Error code | Meaning | Typical cause |
|---|---|---|
ExpiredTokenException |
Temporary credentials expired | STS session or assumed-role token reached TTL |
InvalidClientTokenId |
Access key does not exist | Key was deleted or never created in this region |
SignatureDoesNotMatch |
Secret key is wrong | Misconfigured env var or wrong profile |
AccessDenied |
SCP or permission boundary blocking | Organization policy restricts this account/region |
Frequently asked questions
What's the difference between IAM permission boundaries and SCPs?
Service Control Policies (SCPs) are organization-level guardrails — they limit the maximum permissions any principal in an AWS Organization account can have. Permission boundaries are account-level guardrails set on individual IAM users or roles — they limit what that specific entity can do even if attached policies would allow more. Both are evaluated as "deny unless permitted" layers, meaning effective permissions are the intersection of all applicable policies. The iam:SimulatePrincipalPolicy API accounts for both layers automatically, which is why it's the correct tool for effective permission analysis rather than reading policy documents manually.
How do I implement cross-account access from an MCP server?
Use sts:AssumeRole with a role in the target account. The role's trust policy must include your calling account or specific principal ARN in its Principal element. Your calling identity needs sts:AssumeRole in its permission policy. In the MCP tool, use the returned temporary credentials to construct a new SDK client for operations in the target account. Cache those credentials until near-expiry (subtract 60 seconds from Expiration). Do not use IAM access keys directly in target accounts — role-based access is auditable (shows up as assumed-role/session-name in CloudTrail) and avoids long-lived key management.
Why does SimulatePrincipalPolicy sometimes return "allowed" but the actual call is denied?
Policy simulation doesn't account for resource-based policies (like S3 bucket policies or KMS key policies) that explicitly deny access. It also doesn't evaluate cross-account scenarios correctly if the role doing the simulation has limited resource visibility. Additionally, some actions have context-key conditions (aws:SourceIp, aws:RequestedRegion) that the simulator evaluates as "not applicable" unless you explicitly set context keys in the simulation request. For the highest-fidelity result, pass ContextEntries that match your actual request context.
What's the maximum duration for an assumed role session?
The default maximum is 1 hour (3600 seconds). Role administrators can extend it to up to 12 hours (43200 seconds) by setting MaxSessionDuration on the role. However, if you're chaining role assumptions (assuming a role that then assumes another role), the session duration is capped at the source role's remaining lifetime. For long-running MCP servers, implement automatic renewal: re-assume the role when less than 15 minutes remain on the current session.
Further reading
- MCP Server Cloudflare Access — JWT validation and access policy management
- MCP Server Okta — user lifecycle, group management, and SSO integration
- MCP Server HashiCorp Vault — secrets management and AppRole auth
- MCP Server Authentication — token management and security patterns
- MCP Server Health Check — wiring /health endpoints for uptime monitoring