Skip to main content
AWS · lesson 2 of 10
AWS beginner Lesson 2 of 10

Also part of: Cloud Security

IAM: Users, Roles, and Policies

Create a least-privilege IAM policy, attach it to a user, hit a real AccessDenied error, then read the policy to see exactly what was missing.

By SkillByExample · Published

aws iam security

Every AWS API call is checked against IAM policies before it runs. This lesson creates a policy narrow enough to do one job, attaches it to a user, deliberately hits the wall it creates, and then fixes it — because the error message is more instructive than the working command.

This lesson uses a real AWS free-tier account. LocalStack’s open-source edition accepts any credentials and does not evaluate policies, so AccessDenied never fires against it — permission enforcement is a paid-tier feature there. IAM users, policies, and role assumption also cost nothing on real AWS, so there is no billing risk in following along.

Step 1: create a user with no permissions

aws iam create-user --user-name deploy-bot
$ aws iam create-user --user-name deploy-bot
{
    "User": {
        "Path": "/",
        "UserName": "deploy-bot",
        "UserId": "AIDAQZX5X6RJXTGF3K7NM",
        "Arn": "arn:aws:iam::482719503661:user/deploy-bot",
        "CreateDate": "2026-09-13T10:02:41+00:00"
    }
}
aws iam create-access-key --user-name deploy-bot
$ aws iam create-access-key --user-name deploy-bot
{
    "AccessKey": {
        "UserName": "deploy-bot",
        "AccessKeyId": "AKIAX7Q3NM6RVYFGH2LP",
        "Status": "Active",
        "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
        "CreateDate": "2026-09-13T10:02:55+00:00"
    }
}

A brand-new IAM user can authenticate — the keys work — but can do nothing yet. No policy is attached.

Step 2: prove it with an S3 call

aws configure --profile deploy-bot
aws --profile deploy-bot s3 cp report.csv s3://acme-reports-2026/exports/report.csv
$ aws --profile deploy-bot s3 cp report.csv s3://acme-reports-2026/exports/report.csv
upload failed: ./report.csv to s3://acme-reports-2026/exports/report.csv An error occurred
(AccessDenied) when calling the PutObject operation: User: arn:aws:iam::482719503661:user/deploy-bot
is not authorized to perform: s3:PutObject on resource:
"arn:aws:s3:::acme-reports-2026/exports/report.csv" because no identity-based policy
allows the s3:PutObject action

This is the message to read carefully every time: it names the exact action (s3:PutObject), the exact resource ARN, and the reason (no identity-based policy allows it). Nothing is ambiguous about what is missing.

Step 3: write a least-privilege policy

deploy-bot should be able to read and write objects under one prefix in one bucket — nothing else. Not s3:*, not Resource: "*".

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadWriteExportsPrefix",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::acme-reports-2026/exports/*"
    },
    {
      "Sid": "ListBucketExportsOnly",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::acme-reports-2026",
      "Condition": {
        "StringLike": { "s3:prefix": "exports/*" }
      }
    }
  ]
}
aws iam create-policy \
  --policy-name deploy-bot-exports-rw \
  --policy-document file://deploy-bot-policy.json
$ aws iam create-policy --policy-name deploy-bot-exports-rw --policy-document file://deploy-bot-policy.json
{
    "Policy": {
        "PolicyName": "deploy-bot-exports-rw",
        "PolicyId": "ANPAQZX5X6RJZ3F8K2LMN",
        "Arn": "arn:aws:iam::482719503661:policy/deploy-bot-exports-rw",
        "Path": "/",
        "DefaultVersionId": "v1",
        "AttachmentCount": 0,
        "IsAttachable": true,
        "CreateDate": "2026-09-13T10:11:02+00:00"
    }
}

Step 4: attach it and retry

aws iam attach-user-policy \
  --user-name deploy-bot \
  --policy-arn arn:aws:iam::482719503661:policy/deploy-bot-exports-rw
aws --profile deploy-bot s3 cp report.csv s3://acme-reports-2026/exports/report.csv
$ aws --profile deploy-bot s3 cp report.csv s3://acme-reports-2026/exports/report.csv
upload: ./report.csv to s3://acme-reports-2026/exports/report.csv

Same command, same user, same credentials — the only thing that changed is the attached policy. Note: IAM changes can take a few seconds to propagate globally; a retry after a short pause is normal if the first attempt after attaching still shows AccessDenied.

Step 5: prove the narrowness works

deploy-bot should still be denied outside exports/, and denied entirely on a different bucket — that is what “least privilege” is actually buying you.

aws --profile deploy-bot s3 cp report.csv s3://acme-reports-2026/archive/report.csv
$ aws --profile deploy-bot s3 cp report.csv s3://acme-reports-2026/archive/report.csv
upload failed: ./report.csv to s3://acme-reports-2026/archive/report.csv An error occurred
(AccessDenied) when calling the PutObject operation: User: arn:aws:iam::482719503661:user/deploy-bot
is not authorized to perform: s3:PutObject on resource:
"arn:aws:s3:::acme-reports-2026/archive/report.csv" because no identity-based policy
allows the s3:PutObject action

Exactly the same error as step 2, for exactly the reason you’d expect: the policy’s Resource only covers exports/*.

Roles: no long-lived keys at all

A user’s access keys are permanent until rotated by hand. A role hands out temporary credentials on assumption — the pattern every EC2 instance and Lambda function should use instead of embedding a user’s keys.

aws sts assume-role \
  --role-arn arn:aws:iam::482719503661:role/reporting-readonly \
  --role-session-name ad-hoc-check
$ aws sts assume-role --role-arn arn:aws:iam::482719503661:role/reporting-readonly --role-session-name ad-hoc-check
{
    "Credentials": {
        "AccessKeyId": "ASIAQZX5X6RJP7NW4KTX",
        "SecretAccessKey": "9drTJvcXLB2p8FvVsJfHo5EzKN1QY3aRWuJhpMLE",
        "SessionToken": "IQoJb3JpZ2luX2VjEIf...truncated...ZXhhbXBsZQ==",
        "Expiration": "2026-09-13T11:12:41+00:00"
    },
    "AssumedRoleUser": {
        "AssumedRoleId": "AROAQZX5X6RJP7NW4KTX:ad-hoc-check",
        "Arn": "arn:aws:sts::482719503661:assumed-role/reporting-readonly/ad-hoc-check"
    }
}

The AccessKeyId starts with ASIA (temporary) instead of AKIA (long-lived), and Expiration is one hour out — these credentials stop working on their own even if never revoked. A leaked ASIA key is a bounded incident; a leaked AKIA key is not.

Practice

1. Attempt the S3 upload before creating the policy and read the AccessDenied message for the exact action name.
An error occurred (AccessDenied) when calling the PutObject operation: ...
is not authorized to perform: s3:PutObject on resource: ...

The action is s3:PutObject, not s3:Put or s3:WriteObject — IAM action names match the API operation names exactly, which is why copying them from the AWS API reference beats guessing.

2. Add s3:DeleteObject to the policy and confirm deploy-bot can now delete under exports/.
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
aws --profile deploy-bot s3 rm s3://acme-reports-2026/exports/report.csv
delete: s3://acme-reports-2026/exports/report.csv

Policies must be updated with create-policy-version (policies are versioned, up to five versions retained) — editing the JSON file alone does nothing until a new version is created and set as default.

3. Try the upload again after detaching the policy.
aws iam detach-user-policy --user-name deploy-bot \
  --policy-arn arn:aws:iam::482719503661:policy/deploy-bot-exports-rw
aws --profile deploy-bot s3 cp report.csv s3://acme-reports-2026/exports/report.csv
upload failed: ... AccessDenied ... is not authorized to perform: s3:PutObject ...

Back to square one — permissions are not sticky to prior success, they are evaluated on every single call.

4. List every policy attached to deploy-bot.
aws iam list-attached-user-policies --user-name deploy-bot
{
    "AttachedPolicies": []
}

Empty, matching the detach in the previous task — list-attached-user-policies is the first command to run when a permission error looks wrong and you are not sure what is actually attached.

5. Assume the reporting-readonly role and confirm it cannot write objects.
aws configure set aws_access_key_id ASIAQZX5X6RJP7NW4KTX --profile assumed
aws configure set aws_secret_access_key 9drTJvcXLB2p8FvVsJfHo5EzKN1QY3aRWuJhpMLE --profile assumed
aws configure set aws_session_token IQoJb3JpZ2luX2VjEIf...truncated... --profile assumed
aws --profile assumed s3 cp report.csv s3://acme-reports-2026/exports/report.csv
upload failed: ... AccessDenied ... is not authorized to perform: s3:PutObject ...

reporting-readonly lives up to its name — the role’s own attached policy only grants s3:GetObject and s3:ListBucket, regardless of what the assuming identity could otherwise do.

Next: EC2 — launching an instance behind a security group and reasoning about what “least privilege” looks like for network access instead of API actions.

Frequently Asked Questions

Why does this lesson need a real AWS account instead of LocalStack?
LocalStack Community does not enforce IAM permissions — every call succeeds regardless of the caller's policy, so an AccessDenied error never happens. Permission enforcement is a Pro-tier feature. This lesson needs the real behaviour, so it runs against a real AWS free-tier account instead.
What is the difference between an IAM user, a role, and a policy?
A policy is a JSON document listing allowed or denied actions. A user is a long-lived identity, typically for a person or an application running outside AWS, that has policies attached directly or through a group. A role is an identity with no permanent credentials of its own — something (a person, an EC2 instance, a Lambda function) assumes it temporarily and gets short-lived credentials for exactly as long as the assumption lasts.
What does least privilege mean in practice?
Granting only the specific actions and resources a task needs, not a broad managed policy like AmazonS3FullAccess. In this lesson that means s3:GetObject and s3:PutObject scoped to one bucket's prefix, not s3:* on Resource: "*". The narrower policy is what turns a compromised credential into a contained incident instead of an account-wide one.
How do I debug an AccessDenied error?
Read the action and resource named in the error message, then check three things in order: does the identity have any policy at all attached, does that policy's Action list include the exact API call being made (s3:PutObject is not the same as s3:PutObjectAcl), and does the Resource ARN in the policy match the ARN being accessed, including the trailing /* for objects inside a bucket.