AWS

AWS IAM Explained: Users, Roles, Policies, and Least Privilege

By Raghvendra Pandey · June 2026 · 10 min read

AWS IAM (Identity and Access Management) is the authorization layer for everything in AWS. Every API call — whether from the console, CLI, an EC2 instance, or a Lambda function — is evaluated against IAM policy. Get it wrong and you either lock out legitimate services or leave your infrastructure open to lateral movement. Despite its importance, IAM is where most teams accumulate the most technical debt, usually in the form of overly broad policies that were "temporary" and became permanent.

This guide covers how IAM actually works — the principal model, how policies are evaluated, role assumption, and the patterns used most in production infrastructure.

Principals: who is making the request

Every IAM interaction starts with a principal — the entity making the API call. AWS has four types of principals:

IAM users are long-lived identities with permanent credentials. They authenticate with a username/password for the console or an access key pair for the CLI/API. Creating IAM users for human access is legacy practice now — most organizations use SSO (AWS IAM Identity Center, formerly SSO) to federate human identities from an identity provider like Okta or Azure AD, then assume roles within AWS accounts. IAM users still make sense for service accounts that need permanent, programmatic API access, though roles are preferred even there when possible.

IAM roles are identities that can be assumed. They don't have permanent credentials — instead, when a principal assumes a role, STS (Security Token Service) issues temporary credentials that expire (typically 1–12 hours). Roles are the preferred mechanism for granting AWS services access to other AWS services, for cross-account access, and for federated human access.

IAM groups are collections of users. A group has policies attached to it; all users in the group inherit those permissions. Groups don't support role assumption — they're only for organizing users and applying policies in bulk.

AWS service principals represent AWS services (EC2, Lambda, ECS, etc.) acting on your behalf. When you create an EC2 instance profile or a Lambda execution role, you're granting an AWS service the ability to assume that role. The service principal looks like ec2.amazonaws.com or lambda.amazonaws.com.

Policies: what the principal is allowed to do

Policies are JSON documents that specify what actions are allowed or denied on which resources under what conditions. IAM has six types of policies:

Identity-based policies are attached directly to users, groups, or roles. They specify what that principal is allowed (or denied) to do.

Resource-based policies are attached to resources (S3 buckets, SQS queues, KMS keys, Lambda functions, etc.) rather than to principals. They specify who is allowed to do what with that resource. S3 bucket policies are the most common example. Resource-based policies support cross-account access without role assumption — you can grant a principal in another account direct access to an S3 bucket.

Permission boundaries cap the maximum permissions an IAM user or role can have. Even if an identity-based policy grants s3:*, if the permission boundary only allows s3:GetObject, only GetObject is allowed. This is useful for delegating IAM management — you can let teams create their own roles as long as those roles don't exceed the boundary.

Service Control Policies (SCPs) apply to entire AWS Organizations accounts. An SCP can prevent all accounts in an OU from using specific regions or services regardless of what IAM policies in those accounts say. SCPs are a ceiling, not a floor — they don't grant permissions, they only restrict them.

Session policies are optional policies passed at role assumption time via sts:AssumeRole or sts:GetFederationToken. They further restrict the permissions for that specific session without modifying the role itself.

Endpoint policies restrict what specific VPC endpoints can access.

How IAM evaluates a request

When an API call arrives, IAM evaluates it through a multi-step decision tree. Understanding this order is essential for diagnosing access issues:

  1. Explicit deny check: If any applicable policy (identity, resource, SCP, permission boundary) has an explicit Deny for the action, the request is denied immediately. Explicit denies always win.
  2. SCP check: If the account is in an Organization and an SCP doesn't allow the action, the request is denied regardless of IAM policies in the account.
  3. Resource-based policy check: If a resource-based policy explicitly allows the request (and there's no explicit deny), it's allowed. This is how cross-account access without role assumption works — the resource policy grants access to a principal in another account directly.
  4. Identity-based policy check: If an identity-based policy attached to the principal allows the action, it's allowed.
  5. Permission boundary check: If a permission boundary is set, the intersection of what the identity-based policy allows and what the boundary allows is the effective permission set.
  6. Default deny: If none of the above granted access, the request is denied. AWS defaults to deny-everything.

The practical takeaway: cross-account access needs to be allowed in both the identity-based policy on the source side AND the resource-based policy on the target side. A common mistake is adding a resource-based policy without giving the source principal permission to assume the role or access the resource.

Policy anatomy

An IAM policy document is JSON with a Statement array. Each statement has an Effect (Allow or Deny), Action (which API operations), Resource (which ARNs), and optionally a Condition block:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::my-company-artifacts/*"
    },
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::my-company-artifacts",
      "Condition": {
        "StringLike": {
          "s3:prefix": ["releases/*"]
        }
      }
    }
  ]
}

The Condition block can restrict access by source IP, time of day, MFA status, required tags, and many other attributes. Conditions don't grant permissions — they further restrict when an Allow applies.

The Resource field is where least privilege is enforced. Instead of "Resource": "*", use the actual ARN of the specific resource. For S3, arn:aws:s3:::bucket-name grants access to the bucket itself (for ListBucket), while arn:aws:s3:::bucket-name/* grants access to objects inside it (for GetObject, PutObject).

Trust policies and role assumption

Every IAM role has two components: an identity-based policy (what the role can do) and a trust policy (who can assume the role). The trust policy is a resource-based policy on the role itself.

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

This trust policy allows EC2 instances to assume the role. For human cross-account access, the principal would be an IAM role or user ARN from another account. For OIDC-based access (GitHub Actions, EKS pods via IRSA), the principal is an OIDC provider with a condition that matches the federated identity claim.

Common patterns with Terraform

EC2 instance profile

An instance profile is the mechanism that attaches an IAM role to an EC2 instance. When EC2 starts an instance with an instance profile, the instance metadata endpoint (169.254.169.254 or IMDSv2 equivalent) provides temporary credentials for the attached role.

resource "aws_iam_role" "app_instance" {
  name = "app-instance-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "app_instance" {
  role = aws_iam_role.app_instance.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["s3:GetObject"]
        Resource = "arn:aws:s3:::${var.config_bucket}/*"
      },
      {
        Effect   = "Allow"
        Action   = ["secretsmanager:GetSecretValue"]
        Resource = "arn:aws:secretsmanager:${var.region}:${var.account_id}:secret:app/*"
      }
    ]
  })
}

resource "aws_iam_instance_profile" "app" {
  name = "app-instance-profile"
  role = aws_iam_role.app_instance.name
}

Lambda execution role

Lambda functions need a role with at minimum the AWSLambdaBasicExecutionRole managed policy (for CloudWatch Logs). Add only the additional permissions the function actually needs:

resource "aws_iam_role" "lambda_execution" {
  name = "my-function-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "lambda_specific" {
  role = aws_iam_role.lambda_execution.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["dynamodb:GetItem", "dynamodb:PutItem"]
      Resource = aws_dynamodb_table.data.arn
    }]
  })
}

IRSA — IAM Roles for Service Accounts (EKS)

IRSA allows Kubernetes pods to assume IAM roles without sharing credentials at the node level. The trust policy uses the cluster's OIDC provider:

data "aws_iam_openid_connect_provider" "eks" {
  url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}

resource "aws_iam_role" "pod_role" {
  name = "my-app-pod-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = data.aws_iam_openid_connect_provider.eks.arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "${trimprefix(data.aws_iam_openid_connect_provider.eks.url, "https://")}:sub" =
            "system:serviceaccount:${var.namespace}:${var.service_account_name}"
        }
      }
    }]
  })
}

The Kubernetes ServiceAccount is annotated with the role ARN. When a pod runs with that ServiceAccount, the EKS Pod Identity webhook injects environment variables that direct the AWS SDK to use WebIdentity tokens for authentication.

Cross-account access

Cross-account access requires granting permission on both sides. In the target account, create a role with a trust policy that allows the source account:

# In the target account (account B)
resource "aws_iam_role" "cross_account" {
  name = "cross-account-access-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = "arn:aws:iam::${var.source_account_id}:root" }
      Action    = "sts:AssumeRole"
      Condition = {
        StringEquals = {
          "sts:ExternalId" = var.external_id  # prevents confused deputy attacks
        }
      }
    }]
  })
}

# In the source account (account A), grant the permission to assume
resource "aws_iam_role_policy" "assume_cross_account" {
  role = aws_iam_role.deployer.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "sts:AssumeRole"
      Resource = "arn:aws:iam::${var.target_account_id}:role/cross-account-access-role"
    }]
  })
}

Implementing least privilege

The principle of least privilege means granting only the permissions needed to perform a specific function and nothing more. In practice, it means:

AWS's IAM Access Analyzer and the IAM Policy Simulator help audit existing policies. Access Analyzer also has a feature that generates least-privilege policies from CloudTrail logs — it observes what an identity actually calls over a period and generates a policy based on observed usage. This is the most practical approach for brownfield permissions: run the role in production, observe for 30–90 days, then generate a tight policy from actual usage.

Common mistakes

AdministratorAccess on service roles. The policy arn:aws:iam::aws:policy/AdministratorAccess grants full access to everything in the account. Attaching this to a Lambda function or EC2 instance because "it needs to do a lot of things" is a security catastrophe waiting to happen. A compromised instance or function with AdministratorAccess can exfiltrate data, create new users, disable logging, or delete everything.

Storing long-lived access keys in code or environment variables. Access keys associated with IAM users don't expire. If they leak (in a git commit, an S3 bucket, logs), they're valid indefinitely until rotated. Use IAM roles and instance metadata instead. For CI/CD, use OIDC federation (GitHub Actions OIDC, GitLab OIDC) rather than static keys.

Shared IAM users across services or teams. One IAM user shared by multiple services means you can't audit which service made which API call, can't rotate credentials for one service without affecting others, and can't revoke access for one service without affecting all. Each service gets its own role.

Permission boundaries not used for delegated IAM. If you let application teams create their own IAM roles, they can escalate their own privileges. A permission boundary constrains what roles they can create — the team can create roles, but those roles can't exceed what the permission boundary allows.

Debugging access issues

When an IAM error appears (AccessDeniedException or is not authorized to perform: on resource: ), the debug workflow is:

  1. Identify the principal in the error — it's in the message as "User: arn:aws:iam::..."
  2. Use the IAM Policy Simulator in the console to test whether that principal has the required permission
  3. Check for explicit denies — SCPs, permission boundaries, resource policies
  4. Check that resource-based policies allow the cross-account action if applicable
  5. Enable CloudTrail data events if not already enabled — access denials are logged there with full context

IAM is complex enough that AWS's documentation for specific services often lists exactly which IAM permissions are needed. The IAM actions reference at docs.aws.amazon.com/service-authorization/latest/reference/ is the authoritative source for every service, action, resource type, and condition key.

Related articles