Aws

IAM Best Practices for Application Development

Implement AWS IAM security best practices for Node.js applications with least privilege roles, cross-account access, and policy management

IAM Best Practices for Application Development

Overview

AWS Identity and Access Management is the foundation of every secure cloud application, yet it remains one of the most misunderstood services in the AWS ecosystem. IAM controls who can do what across your entire AWS infrastructure, and getting it wrong means either locking yourself out of your own resources or leaving the door wide open for attackers. This article covers the practical IAM patterns that every application developer needs to know, from least privilege policies to cross-account role assumption, with working Node.js examples you can adapt to your own projects.

Prerequisites

  • An AWS account with administrative access
  • Node.js v16 or later installed
  • AWS CLI configured with credentials (aws configure)
  • Basic familiarity with AWS services (S3, Lambda, EC2)
  • The AWS SDK for JavaScript v3 installed:
npm install @aws-sdk/client-iam @aws-sdk/client-sts @aws-sdk/client-s3 @aws-sdk/credential-providers

Understanding IAM Principals

Every request to AWS is made by a principal. A principal is an entity that can be authenticated and authorized to perform actions. Understanding the different types of principals is the first step toward building a secure IAM strategy.

IAM Users

IAM users are long-lived identities with permanent credentials. They are the simplest principal type, but they are also the most dangerous when misused. Every IAM user has an access key ID and secret access key that never expire unless you explicitly rotate or deactivate them.

var AWS_IAM = require("@aws-sdk/client-iam");
var IAMClient = AWS_IAM.IAMClient;
var CreateUserCommand = AWS_IAM.CreateUserCommand;
var CreateAccessKeyCommand = AWS_IAM.CreateAccessKeyCommand;

var iam = new IAMClient({ region: "us-east-1" });

function createServiceUser(username) {
  return iam.send(new CreateUserCommand({
    UserName: username,
    Tags: [
      { Key: "Purpose", Value: "service-account" },
      { Key: "ManagedBy", Value: "automation" }
    ]
  }))
  .then(function(user) {
    console.log("Created user:", user.User.Arn);
    return iam.send(new CreateAccessKeyCommand({
      UserName: username
    }));
  })
  .then(function(keyResult) {
    console.log("Access Key ID:", keyResult.AccessKey.AccessKeyId);
    console.log("Store the secret key securely. You will not see it again.");
    return keyResult.AccessKey;
  });
}

The rule is simple: do not create IAM users for applications. Use roles instead. IAM users should be reserved for human operators who need console access, and even then, you should enforce MFA.

IAM Groups

Groups let you attach policies to a collection of users rather than managing permissions on individual users. This is essential for any team larger than two or three people.

var AttachGroupPolicyCommand = AWS_IAM.AttachGroupPolicyCommand;
var CreateGroupCommand = AWS_IAM.CreateGroupCommand;
var AddUserToGroupCommand = AWS_IAM.AddUserToGroupCommand;

function setupDeveloperGroup() {
  return iam.send(new CreateGroupCommand({
    GroupName: "Developers"
  }))
  .then(function() {
    return iam.send(new AttachGroupPolicyCommand({
      GroupName: "Developers",
      PolicyArn: "arn:aws:iam::aws:policy/ReadOnlyAccess"
    }));
  })
  .then(function() {
    return iam.send(new AddUserToGroupCommand({
      GroupName: "Developers",
      UserName: "jane.doe"
    }));
  })
  .then(function() {
    console.log("Developer group configured.");
  });
}

IAM Roles

Roles are the preferred mechanism for granting permissions to applications, services, and cross-account access. Unlike users, roles provide temporary credentials that automatically rotate. When your Node.js application runs on EC2, Lambda, or ECS, it should always use a role, never hardcoded keys.

A role consists of two parts: a trust policy that defines who can assume the role, and one or more permission policies that define what the role can do.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

This trust policy allows the Lambda service to assume the role. Without this trust relationship, no Lambda function can use this role, regardless of what permission policies are attached.

Policy Types

IAM has several policy types, and understanding when to use each one is critical.

Identity-Based Policies

These are the most common. They attach directly to users, groups, or roles and define what actions those principals can perform.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowS3BucketAccess",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-app-data",
        "arn:aws:s3:::my-app-data/*"
      ]
    }
  ]
}

Notice the two resource ARNs. The bucket ARN (arn:aws:s3:::my-app-data) is required for ListBucket. The object ARN with the wildcard (arn:aws:s3:::my-app-data/*) is required for GetObject and PutObject. Forgetting this distinction is one of the most common IAM mistakes.

Resource-Based Policies

These attach to the resource itself, not the principal. S3 bucket policies, SQS queue policies, and Lambda function policies are all resource-based policies. They are the only way to grant cross-account access without requiring the other account to assume a role.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CrossAccountRead",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:root"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::shared-data-bucket/*"
    }
  ]
}

Permission Boundaries

Permission boundaries are an advanced feature that sets the maximum permissions a role or user can have. Even if you attach an AdministratorAccess policy to a user, a permission boundary can restrict them to only S3 and DynamoDB operations.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowOnlySpecificServices",
      "Effect": "Allow",
      "Action": [
        "s3:*",
        "dynamodb:*",
        "logs:*",
        "cloudwatch:*"
      ],
      "Resource": "*"
    }
  ]
}

Permission boundaries are essential for delegated administration. If you allow developers to create their own IAM roles for Lambda functions, you should enforce a permission boundary so those roles cannot escalate privileges beyond what you intend.

var CreateRoleCommand = AWS_IAM.CreateRoleCommand;
var PutRolePolicyCommand = AWS_IAM.PutRolePolicyCommand;

function createBoundedRole(roleName, boundaryPolicyArn) {
  var trustPolicy = {
    Version: "2012-10-17",
    Statement: [{
      Effect: "Allow",
      Principal: { Service: "lambda.amazonaws.com" },
      Action: "sts:AssumeRole"
    }]
  };

  return iam.send(new CreateRoleCommand({
    RoleName: roleName,
    AssumeRolePolicyDocument: JSON.stringify(trustPolicy),
    PermissionsBoundary: boundaryPolicyArn,
    Tags: [
      { Key: "Environment", Value: "production" }
    ]
  }))
  .then(function(result) {
    console.log("Created role with permission boundary:", result.Role.Arn);
    return result.Role;
  });
}

The Least Privilege Principle

Least privilege means granting only the permissions required to perform a task and nothing more. In practice, this is difficult to achieve on day one. Most teams start with overly broad policies and refine them over time. That approach is fine, as long as you actually refine them.

The process looks like this:

  1. Start with broad permissions during development.
  2. Use CloudTrail to log all API calls made by your application.
  3. Use IAM Access Analyzer to generate a policy based on actual usage.
  4. Replace the broad policy with the generated least-privilege policy.
  5. Test thoroughly before deploying to production.

Here is a policy that follows least privilege for a Node.js application that reads from DynamoDB and writes to an S3 bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DynamoDBReadOnly",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:Query",
        "dynamodb:BatchGetItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/UserProfiles"
    },
    {
      "Sid": "S3WriteReports",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::reports-bucket/daily/*",
      "Condition": {
        "StringEquals": {
          "s3:x-amz-server-side-encryption": "aws:kms"
        }
      }
    }
  ]
}

Notice the condition on the S3 statement. It forces the application to encrypt every object it uploads with KMS. If the application tries to upload without encryption, the request is denied. This is defense in depth.

IAM Roles for Compute Services

EC2 Instance Profiles

When your Node.js application runs on EC2, attach an instance profile with a role. The AWS SDK automatically retrieves temporary credentials from the instance metadata service.

var S3Client = require("@aws-sdk/client-s3").S3Client;
var GetObjectCommand = require("@aws-sdk/client-s3").GetObjectCommand;

// No credentials needed - the SDK uses the instance profile automatically
var s3 = new S3Client({ region: "us-east-1" });

function getConfigFromS3(bucket, key) {
  return s3.send(new GetObjectCommand({
    Bucket: bucket,
    Key: key
  }))
  .then(function(response) {
    return streamToString(response.Body);
  })
  .then(function(body) {
    return JSON.parse(body);
  });
}

function streamToString(stream) {
  return new Promise(function(resolve, reject) {
    var chunks = [];
    stream.on("data", function(chunk) { chunks.push(chunk); });
    stream.on("end", function() { resolve(Buffer.concat(chunks).toString("utf-8")); });
    stream.on("error", reject);
  });
}

Lambda Execution Roles

Lambda functions receive their credentials through the execution role. You define this role when creating the function, and Lambda automatically injects temporary credentials into the environment.

// Lambda handler - credentials come from the execution role
var DynamoDBClient = require("@aws-sdk/client-dynamodb").DynamoDBClient;
var GetItemCommand = require("@aws-sdk/client-dynamodb").GetItemCommand;

var dynamo = new DynamoDBClient({ region: process.env.AWS_REGION });

exports.handler = function(event, context) {
  return dynamo.send(new GetItemCommand({
    TableName: "Orders",
    Key: {
      orderId: { S: event.orderId }
    }
  }))
  .then(function(result) {
    return {
      statusCode: 200,
      body: JSON.stringify(result.Item)
    };
  })
  .catch(function(err) {
    console.error("DynamoDB error:", err.message);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: "Internal server error" })
    };
  });
};

ECS Task Roles

ECS supports two types of roles: the task execution role (used by the ECS agent to pull images and write logs) and the task role (used by your application code). Keep these separate. Your application code should never need access to ECR or CloudWatch Logs directly.

Cross-Account Access

Cross-account access is one of IAM's most powerful features. The pattern involves a role in the target account that trusts the source account, and code in the source account that assumes that role.

Setting Up the Trust Relationship

In the target account (Account B), create a role with a trust policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111111111111:role/AppRole"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "unique-external-id-12345"
        }
      }
    }
  ]
}

The ExternalId condition prevents the confused deputy problem, where a malicious third party tricks your application into accessing their resources on their behalf. Always use external IDs for cross-account roles that involve third parties.

Assuming the Role in Node.js

var STSClient = require("@aws-sdk/client-sts").STSClient;
var AssumeRoleCommand = require("@aws-sdk/client-sts").AssumeRoleCommand;
var S3Client = require("@aws-sdk/client-s3").S3Client;
var ListObjectsV2Command = require("@aws-sdk/client-s3").ListObjectsV2Command;

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

function assumeCrossAccountRole(roleArn, externalId, sessionName) {
  return sts.send(new AssumeRoleCommand({
    RoleArn: roleArn,
    ExternalId: externalId,
    RoleSessionName: sessionName || "cross-account-session",
    DurationSeconds: 3600
  }))
  .then(function(response) {
    var creds = response.Credentials;
    return {
      accessKeyId: creds.AccessKeyId,
      secretAccessKey: creds.SecretAccessKey,
      sessionToken: creds.SessionToken,
      expiration: creds.Expiration
    };
  });
}

function listBucketInOtherAccount(roleArn, externalId, bucketName) {
  return assumeCrossAccountRole(roleArn, externalId, "s3-list-session")
    .then(function(credentials) {
      var crossAccountS3 = new S3Client({
        region: "us-east-1",
        credentials: credentials
      });

      return crossAccountS3.send(new ListObjectsV2Command({
        Bucket: bucketName,
        MaxKeys: 100
      }));
    })
    .then(function(result) {
      console.log("Found", result.KeyCount, "objects in cross-account bucket");
      return result.Contents;
    });
}

Policy Conditions and Variables

Conditions transform static policies into dynamic, context-aware access controls. You can restrict access based on IP address, time of day, MFA status, requested region, and dozens of other factors.

Restricting by Source IP

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "NotIpAddress": {
          "aws:SourceIp": [
            "203.0.113.0/24",
            "198.51.100.0/24"
          ]
        },
        "Bool": {
          "aws:ViaAWSService": "false"
        }
      }
    }
  ]
}

The aws:ViaAWSService condition is important. Without it, AWS services calling other services on your behalf (like Lambda invoking DynamoDB) would also be blocked by the IP restriction.

Tag-Based Access Control

Policy variables let you write a single policy that dynamically scopes access based on tags:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::project-data/${aws:PrincipalTag/ProjectId}/*"
    }
  ]
}

This policy grants access only to the S3 prefix matching the user's ProjectId tag. A user tagged with ProjectId: alpha can only access s3://project-data/alpha/*. This scales far better than maintaining separate policies per project.

Service Control Policies

Service Control Policies (SCPs) operate at the AWS Organizations level. They set guardrails that apply to entire accounts, and they override any IAM policies within those accounts. If an SCP denies an action, no amount of IAM Allow statements can override it.

Common SCP patterns include:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyRegionsOutsideUS",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": [
            "us-east-1",
            "us-west-2"
          ]
        },
        "ArnNotLike": {
          "aws:PrincipalARN": "arn:aws:iam::*:role/OrganizationAdmin"
        }
      }
    },
    {
      "Sid": "DenyLeaveOrganization",
      "Effect": "Deny",
      "Action": "organizations:LeaveOrganization",
      "Resource": "*"
    }
  ]
}

This SCP restricts all accounts to us-east-1 and us-west-2 only, with an exception for a designated admin role. It also prevents any account from leaving the organization.

IAM Access Analyzer

IAM Access Analyzer is a service that continuously monitors your policies for unintended access. It identifies resources shared with external entities, validates policies against best practices, and generates least-privilege policies from CloudTrail logs.

var AccessAnalyzerClient = require("@aws-sdk/client-accessanalyzer").AccessAnalyzerClient;
var ListFindingsCommand = require("@aws-sdk/client-accessanalyzer").ListFindingsCommand;
var ValidatePolicyCommand = require("@aws-sdk/client-accessanalyzer").ValidatePolicyCommand;

var analyzer = new AccessAnalyzerClient({ region: "us-east-1" });

function getActiveFindings(analyzerArn) {
  return analyzer.send(new ListFindingsCommand({
    analyzerArn: analyzerArn,
    filter: {
      status: {
        eq: ["ACTIVE"]
      }
    }
  }))
  .then(function(result) {
    result.findings.forEach(function(finding) {
      console.log("Finding:", finding.resourceType, "-", finding.resource);
      console.log("  Principal:", JSON.stringify(finding.principal));
      console.log("  Action:", finding.action);
      console.log("  Condition:", JSON.stringify(finding.condition));
    });
    return result.findings;
  });
}

function validatePolicy(policyDocument) {
  return analyzer.send(new ValidatePolicyCommand({
    policyDocument: JSON.stringify(policyDocument),
    policyType: "IDENTITY_POLICY"
  }))
  .then(function(result) {
    var errors = result.findings.filter(function(f) { return f.findingType === "ERROR"; });
    var warnings = result.findings.filter(function(f) { return f.findingType === "WARNING"; });
    var suggestions = result.findings.filter(function(f) { return f.findingType === "SUGGESTION"; });

    console.log("Validation results:");
    console.log("  Errors:", errors.length);
    console.log("  Warnings:", warnings.length);
    console.log("  Suggestions:", suggestions.length);

    result.findings.forEach(function(f) {
      console.log("[" + f.findingType + "]", f.findingDetails, "-", f.issueCode);
    });

    return result.findings;
  });
}

Managing Credentials Programmatically

The AWS SDK v3 has a flexible credential provider chain. Understanding it prevents a whole class of configuration bugs.

var fromIni = require("@aws-sdk/credential-providers").fromIni;
var fromEnv = require("@aws-sdk/credential-providers").fromEnv;
var fromInstanceMetadata = require("@aws-sdk/credential-providers").fromInstanceMetadata;

// Explicit profile for local development
var devCredentials = fromIni({ profile: "dev-account" });

// From environment variables (CI/CD pipelines)
var ciCredentials = fromEnv();

// From EC2 instance metadata (production)
var prodCredentials = fromInstanceMetadata({
  maxRetries: 3,
  timeout: 1000
});

// Use the appropriate provider based on environment
function getCredentials() {
  if (process.env.NODE_ENV === "production") {
    return prodCredentials;
  }
  if (process.env.CI) {
    return ciCredentials;
  }
  return devCredentials;
}

var s3 = new S3Client({
  region: "us-east-1",
  credentials: getCredentials()
});

Temporary Credentials with STS

AWS Security Token Service issues temporary credentials that automatically expire. You should always prefer temporary credentials over long-lived access keys.

var STSClient = require("@aws-sdk/client-sts").STSClient;
var GetSessionTokenCommand = require("@aws-sdk/client-sts").GetSessionTokenCommand;
var AssumeRoleCommand = require("@aws-sdk/client-sts").AssumeRoleCommand;
var GetCallerIdentityCommand = require("@aws-sdk/client-sts").GetCallerIdentityCommand;

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

function getTemporaryCredentials(durationSeconds) {
  return sts.send(new GetSessionTokenCommand({
    DurationSeconds: durationSeconds || 3600
  }))
  .then(function(response) {
    var creds = response.Credentials;
    console.log("Temporary credentials expire at:", creds.Expiration);
    return {
      accessKeyId: creds.AccessKeyId,
      secretAccessKey: creds.SecretAccessKey,
      sessionToken: creds.SessionToken
    };
  });
}

function whoAmI() {
  return sts.send(new GetCallerIdentityCommand({}))
    .then(function(identity) {
      console.log("Account:", identity.Account);
      console.log("ARN:", identity.Arn);
      console.log("UserId:", identity.UserId);
      return identity;
    });
}

MFA Enforcement

For sensitive operations, require MFA even for programmatic access. This is done through policy conditions and STS.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowWithMFA",
      "Effect": "Allow",
      "Action": [
        "iam:DeleteUser",
        "iam:DeleteRole",
        "s3:DeleteBucket"
      ],
      "Resource": "*",
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        },
        "NumericLessThan": {
          "aws:MultiFactorAuthAge": "3600"
        }
      }
    }
  ]
}

To get MFA-authenticated temporary credentials programmatically:

function getCredentialsWithMFA(mfaSerialNumber, tokenCode) {
  return sts.send(new GetSessionTokenCommand({
    DurationSeconds: 3600,
    SerialNumber: mfaSerialNumber,
    TokenCode: tokenCode
  }))
  .then(function(response) {
    console.log("MFA-authenticated session established");
    return {
      accessKeyId: response.Credentials.AccessKeyId,
      secretAccessKey: response.Credentials.SecretAccessKey,
      sessionToken: response.Credentials.SessionToken
    };
  });
}

// Usage:
// getCredentialsWithMFA("arn:aws:iam::123456789012:mfa/engineer", "123456")

Complete Working Example

This Node.js application demonstrates cross-account role assumption, credential management, and IAM policy auditing in a single cohesive program.

var STSClient = require("@aws-sdk/client-sts").STSClient;
var AssumeRoleCommand = require("@aws-sdk/client-sts").AssumeRoleCommand;
var GetCallerIdentityCommand = require("@aws-sdk/client-sts").GetCallerIdentityCommand;
var IAMClient = require("@aws-sdk/client-iam").IAMClient;
var ListAttachedRolePoliciesCommand = require("@aws-sdk/client-iam").ListAttachedRolePoliciesCommand;
var GetPolicyCommand = require("@aws-sdk/client-iam").GetPolicyCommand;
var GetPolicyVersionCommand = require("@aws-sdk/client-iam").GetPolicyVersionCommand;
var ListRolePoliciesCommand = require("@aws-sdk/client-iam").ListRolePoliciesCommand;
var GetRolePolicyCommand = require("@aws-sdk/client-iam").GetRolePolicyCommand;
var AccessAnalyzerClient = require("@aws-sdk/client-accessanalyzer").AccessAnalyzerClient;
var ValidatePolicyCommand = require("@aws-sdk/client-accessanalyzer").ValidatePolicyCommand;

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

// Step 1: Identify current caller
function identifyCaller() {
  return sts.send(new GetCallerIdentityCommand({}))
    .then(function(identity) {
      console.log("=== Current Identity ===");
      console.log("Account:", identity.Account);
      console.log("ARN:", identity.Arn);
      return identity;
    });
}

// Step 2: Assume a cross-account role
function assumeRole(roleArn, externalId) {
  console.log("\n=== Assuming Role ===");
  console.log("Target:", roleArn);

  var params = {
    RoleArn: roleArn,
    RoleSessionName: "iam-audit-session-" + Date.now(),
    DurationSeconds: 3600
  };

  if (externalId) {
    params.ExternalId = externalId;
  }

  return sts.send(new AssumeRoleCommand(params))
    .then(function(response) {
      console.log("Role assumed successfully. Expires:", response.Credentials.Expiration);
      return {
        accessKeyId: response.Credentials.AccessKeyId,
        secretAccessKey: response.Credentials.SecretAccessKey,
        sessionToken: response.Credentials.SessionToken
      };
    });
}

// Step 3: Audit attached policies for a role
function auditRolePolicies(roleName, credentials) {
  var iamConfig = { region: REGION };
  if (credentials) {
    iamConfig.credentials = credentials;
  }

  var iam = new IAMClient(iamConfig);
  var allPolicies = [];

  console.log("\n=== Auditing Role:", roleName, "===");

  // Get managed policies
  return iam.send(new ListAttachedRolePoliciesCommand({
    RoleName: roleName
  }))
  .then(function(attached) {
    console.log("\nManaged Policies (" + attached.AttachedPolicies.length + "):");

    var promises = attached.AttachedPolicies.map(function(policy) {
      console.log("  -", policy.PolicyName, "(" + policy.PolicyArn + ")");
      return fetchPolicyDocument(iam, policy.PolicyArn);
    });

    return Promise.all(promises);
  })
  .then(function(managedDocs) {
    allPolicies = allPolicies.concat(managedDocs);

    // Get inline policies
    return iam.send(new ListRolePoliciesCommand({
      RoleName: roleName
    }));
  })
  .then(function(inlinePolicies) {
    console.log("\nInline Policies (" + inlinePolicies.PolicyNames.length + "):");

    var promises = inlinePolicies.PolicyNames.map(function(policyName) {
      console.log("  -", policyName);
      return iam.send(new GetRolePolicyCommand({
        RoleName: roleName,
        PolicyName: policyName
      }))
      .then(function(result) {
        return JSON.parse(decodeURIComponent(result.PolicyDocument));
      });
    });

    return Promise.all(promises);
  })
  .then(function(inlineDocs) {
    allPolicies = allPolicies.concat(inlineDocs);
    return analyzePermissions(allPolicies);
  });
}

function fetchPolicyDocument(iam, policyArn) {
  return iam.send(new GetPolicyCommand({ PolicyArn: policyArn }))
    .then(function(policy) {
      return iam.send(new GetPolicyVersionCommand({
        PolicyArn: policyArn,
        VersionId: policy.Policy.DefaultVersionId
      }));
    })
    .then(function(version) {
      return JSON.parse(decodeURIComponent(version.PolicyVersion.Document));
    });
}

// Step 4: Analyze permissions for overly broad access
function analyzePermissions(policyDocuments) {
  var findings = [];

  policyDocuments.forEach(function(doc, index) {
    var statements = Array.isArray(doc.Statement) ? doc.Statement : [doc.Statement];

    statements.forEach(function(stmt) {
      if (stmt.Effect !== "Allow") return;

      var actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
      var resources = Array.isArray(stmt.Resource) ? stmt.Resource : [stmt.Resource];

      // Check for wildcard actions
      actions.forEach(function(action) {
        if (action === "*") {
          findings.push({
            severity: "CRITICAL",
            message: "Wildcard action (*) grants all permissions",
            policy: index
          });
        } else if (action.endsWith(":*")) {
          findings.push({
            severity: "HIGH",
            message: "Full service access: " + action,
            policy: index
          });
        }
      });

      // Check for wildcard resources
      resources.forEach(function(resource) {
        if (resource === "*") {
          findings.push({
            severity: "HIGH",
            message: "Wildcard resource (*) - no resource scoping for actions: " + actions.join(", "),
            policy: index
          });
        }
      });

      // Check for missing conditions on sensitive actions
      var sensitiveActions = ["iam:*", "sts:AssumeRole", "s3:*", "ec2:*"];
      var hasSensitive = actions.some(function(a) {
        return sensitiveActions.indexOf(a) !== -1;
      });

      if (hasSensitive && !stmt.Condition) {
        findings.push({
          severity: "MEDIUM",
          message: "Sensitive action without conditions: " + actions.join(", "),
          policy: index
        });
      }
    });
  });

  console.log("\n=== Security Findings ===");
  if (findings.length === 0) {
    console.log("No issues found. Policies follow least privilege.");
  } else {
    findings.forEach(function(f) {
      console.log("[" + f.severity + "] " + f.message);
    });
  }

  return findings;
}

// Step 5: Validate a policy using IAM Access Analyzer
function validateWithAccessAnalyzer(policyDocument) {
  var accessAnalyzer = new AccessAnalyzerClient({ region: REGION });

  return accessAnalyzer.send(new ValidatePolicyCommand({
    policyDocument: JSON.stringify(policyDocument),
    policyType: "IDENTITY_POLICY"
  }))
  .then(function(result) {
    console.log("\n=== Access Analyzer Validation ===");
    result.findings.forEach(function(f) {
      console.log("[" + f.findingType + "]", f.issueCode + ":", f.findingDetails);
    });
    return result.findings;
  });
}

// Main execution
function main() {
  var targetRoleArn = process.env.AUDIT_ROLE_ARN;
  var targetRoleName = process.env.AUDIT_ROLE_NAME || "ApplicationRole";
  var externalId = process.env.EXTERNAL_ID;

  identifyCaller()
    .then(function() {
      if (targetRoleArn) {
        return assumeRole(targetRoleArn, externalId);
      }
      console.log("No AUDIT_ROLE_ARN set. Using current credentials.");
      return null;
    })
    .then(function(credentials) {
      return auditRolePolicies(targetRoleName, credentials);
    })
    .then(function(findings) {
      console.log("\n=== Summary ===");
      console.log("Total findings:", findings.length);

      var critical = findings.filter(function(f) { return f.severity === "CRITICAL"; });
      var high = findings.filter(function(f) { return f.severity === "HIGH"; });

      if (critical.length > 0 || high.length > 0) {
        console.log("ACTION REQUIRED:", critical.length, "critical,", high.length, "high severity issues");
        process.exit(1);
      }

      console.log("All policies pass audit checks.");
    })
    .catch(function(err) {
      console.error("Audit failed:", err.message);
      process.exit(1);
    });
}

main();

Run this with:

AUDIT_ROLE_ARN=arn:aws:iam::222222222222:role/AuditRole \
AUDIT_ROLE_NAME=ApplicationRole \
EXTERNAL_ID=my-external-id \
node iam-audit.js

Common Issues and Troubleshooting

1. Access Denied on AssumeRole

AccessDenied: User: arn:aws:iam::111111111111:user/deploy is not authorized
to perform: sts:AssumeRole on resource: arn:aws:iam::222222222222:role/CrossAccountRole

This happens when either (a) the calling principal does not have sts:AssumeRole permission, or (b) the target role's trust policy does not include the calling principal. Check both sides. Also verify the ExternalId matches exactly if the trust policy requires one.

2. MalformedPolicyDocument on Role Creation

MalformedPolicyDocument: Has prohibited field Resource.

Trust policies (AssumeRolePolicyDocument) do not support the Resource field. If you copy a standard IAM policy and try to use it as a trust policy, you will get this error. Trust policies use Principal instead of Resource.

3. Token Expired During Long Operations

ExpiredTokenException: The security token included in the request is expired

Temporary credentials from STS have a maximum duration. For AssumeRole, the default is one hour. If your operation takes longer, you need to re-assume the role before the credentials expire. Implement credential caching with automatic refresh:

var cachedCredentials = null;
var credentialExpiry = null;

function getValidCredentials(roleArn, externalId) {
  var now = new Date();
  var bufferMs = 5 * 60 * 1000; // refresh 5 minutes before expiry

  if (cachedCredentials && credentialExpiry && (credentialExpiry.getTime() - now.getTime()) > bufferMs) {
    return Promise.resolve(cachedCredentials);
  }

  return assumeRole(roleArn, externalId)
    .then(function(creds) {
      cachedCredentials = creds;
      credentialExpiry = new Date(Date.now() + 3600 * 1000);
      return creds;
    });
}

4. Policy Size Limit Exceeded

LimitExceeded: Cannot exceed quota for PolicySize: 6144

Managed policies have a maximum size of 6,144 characters. Inline policies are limited to 2,048 characters for users, 10,240 for roles, and 5,120 for groups. When you hit this limit, split your policy into multiple managed policies, use wildcards strategically in resource ARNs, or consolidate similar actions. Note that a maximum of 10 managed policies can be attached to a single role.

5. Confused Deputy: Missing ExternalId

If you create a cross-account role for a third party without requiring an ExternalId, another customer of that third party could potentially provide your account ID and trick the service into accessing your resources. Always require sts:ExternalId in the trust policy condition for any role assumed by third-party services.

Best Practices

  • Never hardcode credentials. Use IAM roles for EC2, Lambda, and ECS. Use environment variables or AWS profiles for local development. If you find access keys in source code, rotate them immediately.

  • Enforce MFA for human users. Create an IAM policy that denies all actions except iam:ChangePassword and MFA management unless aws:MultiFactorAuthPresent is true. Apply this to all human IAM users without exception.

  • Use permission boundaries for delegated administration. When developers can create their own Lambda roles, set a permission boundary that prevents privilege escalation. This way, a developer cannot create a role more powerful than their own.

  • Rotate access keys on a schedule. If you must use IAM user access keys (for legacy systems or third-party integrations), rotate them every 90 days. Use iam:CreateAccessKey and iam:DeleteAccessKey to automate the rotation in a Lambda function.

  • Tag everything. Apply tags to roles, users, and policies with metadata like Team, Application, Environment, and CostCenter. Tags enable attribute-based access control (ABAC) and make auditing significantly easier.

  • Use IAM Access Analyzer continuously. Enable Access Analyzer in every region you operate in. Review findings weekly. Use policy validation before deploying new policies to catch issues before they reach production.

  • Prefer managed policies over inline policies. Managed policies are reusable, versionable, and have a central view. Inline policies are harder to audit and cannot be shared between principals.

  • Implement SCPs in AWS Organizations. Even if you have a single account today, set up AWS Organizations and create SCPs. They provide a safety net that cannot be overridden by any IAM policy within the account, protecting against accidental or malicious privilege escalation.

  • Scope resource ARNs as tightly as possible. Instead of arn:aws:s3:::*, use arn:aws:s3:::my-app-prod-bucket/uploads/*. Every wildcard you remove is a blast radius you contain.

  • Monitor with CloudTrail and GuardDuty. CloudTrail logs every API call. GuardDuty flags anomalous behavior like API calls from unusual locations or credential exfiltration attempts. Together, they provide detection coverage that complements IAM's preventive controls.

References

Powered by Contentful