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
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:AssumeRoleon that role ARN. SSO users appear asassumed-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-idExternal 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
Conditionblocks 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 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:
- Owner calls
ram:CreateResourceShare, listing resource ARNs and principals (account IDs, OU ARNs, or the entire org). - Recipients receive an invitation and accept it via
ram:AcceptResourceShareInvitation. Organization-wide shares are accepted automatically. - 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
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_POLICYThis 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:
AssumeRolefor IAM and service principals,AssumeRoleWithWebIdentityfor OIDC providers (GitHub Actions, Kubernetes IRSA),GetSessionTokento add MFA enforcement to long-term keys, andGetCallerIdentityto debug active identity - External ID in a trust policy condition prevents the confused deputy problem when granting cross-account access to third-party services