IAM: Identity & Access

Master users, groups, roles, and policies to control who can do what in your AWS account, and learn why roles are almost always better than access keys.

Introduction

IAM (Identity and Access Management) is the security backbone of every AWS account. Every API call to every AWS service is authorized by IAM. Get IAM wrong and you either lock yourself out or, far worse, leave your account open to attackers. This lesson covers the four core IAM primitives and the patterns that keep production accounts secure.

1. Users, Groups, Roles, and Policies

IAM Primitives and How They Relate

USRAliceUSRBobIAMGroup: DevsIAMPolicyIAMRoleλLambda Fnmember ofmember ofattachedassumesattached

Users belong to groups; both can have policies attached. Lambda assumes a role, which also has policies.

Users

A person or a machine identity with long-term credentials (password for console, access key for CLI). Use for human operators; avoid for application code.

Groups

A collection of users. Attach policies to groups, not individual users. A user inherits all permissions from all their groups.

Roles

A set of permissions that can be assumed by AWS services (Lambda, EC2), other AWS accounts, or federated identities. Roles have no long-term credentials, they issue temporary tokens via STS.

Policies

JSON documents that define allowed/denied actions on specific resources. Attached to users, groups, or roles. AWS provides 1000+ managed policies; write inline policies when you need exact scope control.

2. Writing IAM Policies

Every IAM policy is a JSON document with one or more statements. Each statement has an Effect (Allow or Deny), a list of Action strings, and a Resource ARN. Deny always wins over Allow.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::my-app-bucket/*"
    },
    {
      "Effect": "Deny",
      "Action": "s3:DeleteObject",
      "Resource": "*"
    }
  ]
}

ARN format: arn:partition:service:region:account-id:resource

Use * as a wildcard. arn:aws:s3:::my-bucket/* matches all objects inside the bucket. arn:aws:s3:::my-bucket (no slash) matches only the bucket itself, not its contents.

3. Roles and Trust Policies

A role has two parts: a permission policy (what the role can do) and a trust policy (who can assume the role). This trust policy allows Lambda to assume the role:

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

Create the role and attach a managed policy via the CLI:

# Create a role with a trust policy (Lambda can assume it)
aws iam create-role \
  --role-name MyLambdaRole \
  --assume-role-policy-document file://trust-policy.json

# Attach a managed policy granting basic Lambda execution
aws iam attach-role-policy \
  --role-name MyLambdaRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

# List policies attached to a role
aws iam list-attached-role-policies --role-name MyLambdaRole

Any principal (service or account) can temporarily assume a role using STS. The returned credentials expire after 15 minutes to 12 hours. For this to work, the role's trust policy must explicitly allow your principal:

# sts:AssumeRole only works when the TARGET role's trust policy
# explicitly allows YOUR principal. MyLambdaRole trusts lambda.amazonaws.com,
# so humans can never assume it; that is intentional.
#
# For cross-account or human-assumable roles, create a dedicated role:
aws iam create-role \
  --role-name CrossAccountRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::ACCOUNT_ID:root"
      },
      "Action": "sts:AssumeRole"
    }]
  }'
Expected Output:
{
  "Role": {
    "Path": "/",
    "RoleName": "CrossAccountRole",
    "RoleId": "AROAR**************",
    "Arn": "arn:aws:iam::ACCOUNT_ID:role/CrossAccountRole",
    "CreateDate": "2026-06-05T17:53:45+00:00",
    "AssumeRolePolicyDocument": {
      "Version": "2012-10-17",
      "Statement": [{
        "Effect": "Allow",
        "Principal": { "AWS": "arn:aws:iam::ACCOUNT_ID:root" },
        "Action": "sts:AssumeRole"
      }]
    }
  }
}

Then assume it:

aws sts assume-role \
  --role-arn arn:aws:iam::ACCOUNT_ID:role/CrossAccountRole \
  --role-session-name my-session
Expected Output:
{
  "Credentials": {
    "AccessKeyId":     "ASIA**************",
    "SecretAccessKey": "******************************",
    "SessionToken":    "******************************",
    "Expiration":      "2026-06-05T18:54:18+00:00"
  },
  "AssumedRoleUser": {
    "AssumedRoleId": "AROAR**************:my-session",
    "Arn": "arn:aws:sts::ACCOUNT_ID:assumed-role/CrossAccountRole/my-session"
  }
}

# Export the credentials to use them in subsequent CLI calls
export AWS_ACCESS_KEY_ID=ASIA**************
export AWS_SECRET_ACCESS_KEY=******************************
export AWS_SESSION_TOKEN=******************************

To clean up, detach any policies first, then delete the role:

# Check what policies are attached before attempting to detach
aws iam list-attached-role-policies --role-name CrossAccountRole

# Detach each policy that appears in the output above (skip if none)
aws iam detach-role-policy \
  --role-name CrossAccountRole \
  --policy-arn arn:aws:iam::aws:policy/POLICY_NAME

# Delete the role (will fail if any policies are still attached)
aws iam delete-role --role-name CrossAccountRole

# MyLambdaRole had AWSLambdaBasicExecutionRole attached at creation; detach first
aws iam detach-role-policy \
  --role-name MyLambdaRole \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

aws iam delete-role --role-name MyLambdaRole

AccessDenied on sts:AssumeRole is a two-sided check. Both must be true for the call to succeed:

  • The role's trust policy must list your principal. Lambda execution roles only trust lambda.amazonaws.com, so humans can never assume them regardless of their own permissions. This is correct behavior.
  • Your permission policy must allow sts:AssumeRole on that role ARN. SSO users appear as assumed-role/AWSReservedSSO_..., so the trust policy must match that ARN pattern, not just an IAM user ARN.

For human or cross-account access, create a dedicated role with a trust policy that explicitly names the principal, as shown in the example above.

4. AWS STS: Temporary Credential Mechanics

AWS Security Token Service (STS) is the engine behind every temporary credential in AWS. When Lambda assumes a role, when a developer runs aws sts assume-role, or when a GitHub Actions workflow authenticates via OIDC, an STS API issues short-lived credentials that expire automatically. There are four core STS operations, each suited to a different identity source:

AssumeRole

Cross-account access and service role assumption (covered in Section 3). Any IAM principal or AWS service with permission can call this API.

AssumeRoleWithWebIdentity

OIDC federation. GitHub Actions, Kubernetes IRSA, and Cognito all call this endpoint to exchange a provider-issued JWT for AWS credentials, with no long-term secrets required.

GetSessionToken

Wraps long-term IAM user credentials with an MFA-backed session token. Required when a policy enforces aws:MultiFactorAuthPresent: true before allowing sensitive CLI operations.

GetCallerIdentity

Returns the UserId, Account, and Arn of whatever credentials are currently active. Works with any credential type, requires no special permissions, and never returns access denied.

GetCallerIdentity: Who Am I?

Run this anytime you are unsure which identity your CLI or SDK is using. The Arn field shows whether you are acting as an IAM user, an assumed-role session, or an EC2 instance profile:

aws sts get-caller-identity
Expected Output:
# As an IAM user
{
    "UserId": "AIDAU**************",
    "Account": "123456789012",
    "Arn": "arn:aws:iam::123456789012:user/alice"
}

# After exporting credentials from sts assume-role
{
    "UserId": "AROAR**************:my-session",
    "Account": "123456789012",
    "Arn": "arn:aws:sts::123456789012:assumed-role/CrossAccountRole/my-session"
}

Session Duration

Every AssumeRole call accepts an optional --duration-seconds flag. The minimum is 900 s (15 min); the maximum is controlled by the role's MaxSessionDuration setting (default 3600 s, configurable up to 43200 s):

# Default session duration is 3600 s; extend with --duration-seconds
# Minimum: 900 s (15 min)  Maximum: the role's MaxSessionDuration (default 3600 s, max 43200 s)
aws sts assume-role \
  --role-arn arn:aws:iam::ACCOUNT_ID:role/MyRole \
  --role-session-name my-session \
  --duration-seconds 7200

GetSessionToken issues tokens for up to 129600 s (36 h) for human MFA sessions. AssumeRoleWithWebIdentity credentials are additionally bounded by the identity provider's token TTL - the shorter of the two limits applies.

External ID: Preventing the Confused Deputy Problem

When a third-party SaaS vendor needs access to your account, you create a role their account can assume. The risk: any of that vendor's other customers could trick the vendor's systems into calling sts:AssumeRole on your role using the vendor's own credentials. This is the confused deputy problem.

The fix is an sts:ExternalId condition in the trust policy. The vendor issues you a unique token; you embed it in the trust policy; the vendor must pass the same token on every assumption call:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::VENDOR_ACCOUNT_ID:root"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "your-unique-customer-id"
        }
      }
    }
  ]
}

# The vendor must pass the matching token in every AssumeRole call
aws sts assume-role \
  --role-arn arn:aws:iam::YOUR_ACCOUNT_ID:role/VendorAccessRole \
  --role-session-name vendor-session \
  --external-id your-unique-customer-id

External ID is not a cryptographic secret. Its value comes from the fact that it cannot be obtained by a third party without your involvement, ensuring the vendor's system was invoked deliberately for your account. Pair it with tightly scoped permission policies and a short MaxSessionDuration for all third-party roles.

AssumeRoleWithWebIdentity and OIDC Federation

OIDC-compatible identity providers (GitHub, Google, Kubernetes) can exchange a signed JWT for AWS credentials without storing any access keys. The provider is registered in IAM as an OIDC identity provider and listed as a federated principal in the role's trust policy:

# OIDC providers exchange a signed JWT for short-lived AWS credentials.
# GitHub Actions, Kubernetes IRSA, and Cognito all call this API internally.
aws sts assume-role-with-web-identity \
  --role-arn arn:aws:iam::ACCOUNT_ID:role/GitHubActionsRole \
  --role-session-name github-deploy \
  --web-identity-token file://oidc-token.txt

# In practice the aws-actions/configure-aws-credentials GitHub Action handles
# the token exchange automatically. See Lesson 14 (CI/CD on AWS) for the full
# OIDC provider registration and role trust policy setup.

5. Least Privilege and MFA

Least Privilege

Grant the minimum permissions needed to complete a task. Start with deny-all, then add only what is required. AWS Access Analyzer and IAM policy simulators help you audit and tighten permissions over time.

  • Never use * in both Action and Resource
  • Scope actions to specific services and verbs
  • Add Condition blocks to restrict by IP, time, or MFA
MFA

Enable MFA on all human IAM users, especially the root account. You can enforce MFA with an IAM policy condition so that sensitive operations require a valid MFA token:

"Condition": { "BoolIfExists": { "aws:MultiFactorAuthPresent": "true" } }
Roles vs. Access Keys

Access keys are long-lived credentials stored in files or environment variables. If leaked, they are exploitable indefinitely until rotated. IAM Roles issue temporary credentials that expire automatically. For any code running on AWS (Lambda, EC2, ECS), always assign a role rather than embedding access keys. Reserve access keys only for CI/CD systems that cannot use roles, and rotate them regularly.

6. AWS RAM: Resource Access Manager

AWS Resource Access Manager (RAM) lets you share AWS resources across accounts within your AWS Organization, or with specific external accounts, without duplicating them. The owning account creates a Resource Share; recipient accounts see the shared resource in their own console and can use it directly.

Cross-Account Resource Sharing with RAM

ACCOUNT A (OWNER)CONSUMER ACCOUNTSVPCVPC SubnetTGWTransit GWIAMAWS RAMSVCAccount BSVCAccount Cshared resourceshared resourcesharedshared

Account A shares a VPC Subnet and Transit Gateway via RAM to Accounts B and C.

Commonly shared resource types:

VPC Subnets

Share network infrastructure so multiple accounts can launch resources into a centrally managed VPC.

Transit Gateways

Hub-and-spoke connectivity between VPCs and on-premises networks, shared across accounts in an org.

Route 53 Resolver Rules

Forward DNS queries to on-premises DNS servers across all accounts without duplicating resolver configuration.

License Manager Configs

Centrally track and enforce software license limits across all accounts in your organization.

AWS Glue Data Catalog

Share metadata databases and tables so multiple accounts can query the same data lake without copying catalog entries.

Aurora DB Clusters

Share an Aurora cluster endpoint across accounts for read access, useful for shared reporting databases.

How sharing works:

  1. Owner calls ram:CreateResourceShare, listing resource ARNs and principals (account IDs, OU ARNs, or the entire org).
  2. Recipients receive an invitation and accept it via ram:AcceptResourceShareInvitation. Organization-wide shares are accepted automatically.
  3. The shared resource appears in the recipient's console and is directly usable without creating a copy.

RAM grants resource access, not IAM permissions. A recipient account must still have an IAM policy allowing the relevant service actions. For example, sharing a VPC subnet gives the account visibility of the subnet, but users still need ec2:RunInstances scoped to that subnet before they can launch instances into it.

7. Access Auditing Tools

IAM Access Analyzer

Access Analyzer continuously scans resource-based policies to find resources accessible from outside your zone of trust: your account (default) or your entire AWS Organization. Instead of manually auditing every bucket policy or role trust document, Access Analyzer creates actionable findings whenever it detects unintended external access.

Access Analyzer: Policy Scanning and Findings

IAMAccess AnalyzerS3S3 BucketIAMIAM RoleKMSKMS KeyEXTExternal Acctanalyzesanalyzesanalyzesfinding: public

Access Analyzer scans resource policies and raises a finding when a resource is accessible from outside the zone of trust.

External Access Findings

A resource policy grants access to a principal outside the zone of trust. Examples: an S3 bucket policy that allows * with no conditions, or a KMS key policy that permits a different AWS account to decrypt data.

Unused Access Findings

Roles, users, or permissions that have not been exercised within the analysis window. Unused access findings help automate least-privilege enforcement by surfacing credentials and permissions that can be safely removed.

Policy validation in CI: Before deploying a policy, validate it with:

aws accessanalyzer validate-policy --policy-document file://policy.json --policy-type IDENTITY_POLICY

This catches syntax errors, grammar issues, and security warnings (such as NotAction with Allow) before the policy reaches production.

Zone of trust: By default an analyzer covers a single account. For multi-account setups, create an organization-level analyzer from the management account. It will flag resources shared outside the org boundary, which is almost always unintentional.

Access Advisor

Every IAM user, role, group, and policy has an "Access Advisor" tab in the IAM console. It shows the last time each AWS service was accessed by that principal, with up to 400 days of history. Use it to find permissions that are never exercised and remove them.

What it shows
  • Service name and namespace (e.g., Amazon S3, s3)
  • Last authenticated date, or absent if the service was never accessed
  • Total number of authenticated entities in the window
  • Region of last access
How to use it
  • Open the IAM console, select a user or role, click the Access Advisor tab.
  • Sort by "Last Accessed" ascending to surface services that have never been used.
  • Remove the corresponding actions from the attached policy, then verify nothing is broken.

To pull the same data programmatically:

# Step 1: Start the report generation (async, returns a JobId)
aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::ACCOUNT_ID:role/MyRole

# Step 2: Retrieve results once JobStatus is COMPLETED
aws iam get-service-last-accessed-details \
  --job-id JOB_ID
Expected Output:
{
  "JobStatus": "COMPLETED",
  "ServiceLastAccessed": [
    {
      "ServiceName": "Amazon S3",
      "LastAuthenticated": "2026-05-15T10:23:01+00:00",
      "ServiceNamespace": "s3",
      "TotalAuthenticatedEntities": 1
    },
    {
      "ServiceName": "AWS Lambda",
      "ServiceNamespace": "lambda",
      "TotalAuthenticatedEntities": 0
    },
    {
      "ServiceName": "Amazon EC2",
      "ServiceNamespace": "ec2",
      "TotalAuthenticatedEntities": 0
    }
  ]
}

Pairing Access Advisor with Access Analyzer: Access Analyzer tells you who can reach a resource from outside your zone of trust. Access Advisor tells you which permissions have never been used by an internal principal. Running both gives you a complete least-privilege audit loop: external exposure plus internal over-provisioning.

Key Takeaways

  • Users are for humans with long-term credentials; attach policies via groups, not directly
  • Roles can be assumed by services, applications, and human users; they issue short-lived STS tokens instead of storing long-term secrets
  • Policies are JSON documents - Effect, Action, Resource; Deny always overrides Allow
  • Trust policies define who can assume a role; permission policies define what the role can do
  • Least privilege - start with no permissions, add only what is proven necessary
  • MFA everywhere - on the root account and all human operators, enforced via policy conditions for sensitive operations
  • AWS RAM shares resources (VPC subnets, Transit Gateways, and more) across accounts without duplication; RAM grants resource visibility, but recipients still need IAM policies for the relevant service actions
  • IAM Access Analyzer continuously monitors resource policies and raises findings for external access and unused permissions; validate policies before deployment with aws accessanalyzer validate-policy
  • Access Advisor shows per-service last-access timestamps for any IAM entity; pair it with Access Analyzer to build a complete least-privilege audit loop
  • AWS STS issues all temporary credentials: AssumeRole for IAM and service principals, AssumeRoleWithWebIdentity for OIDC providers (GitHub Actions, Kubernetes IRSA), GetSessionToken to add MFA enforcement to long-term keys, and GetCallerIdentity to debug active identity
  • External ID in a trust policy condition prevents the confused deputy problem when granting cross-account access to third-party services