AWS · 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
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.