If you have ever stared at an AccessDenied error and played permission roulette, adding one action at a time until something works, this post is for you.
We are going to take a short, real S3 bash script, map every single CLI command to the exact IAM permission it requires, and write the smallest possible policy that grants only those actions.
What IAM is (in 60 seconds)
AWS IAM (Identity and Access Management) answers three questions for every API call:
| Question | IAM concept |
|---|---|
| Who is calling? | Identity (user, role, or group) |
| What are they allowed to do? | Policy (JSON allow/deny rules) |
| On which resource? | Resource ARN |
The evaluation logic is simple but strict:
- Default deny — everything is blocked unless explicitly allowed
- Explicit Allow — grants access
- Explicit Deny — always wins, even over an Allow
That third rule is why least-privilege matters: the more permissions you hand out, the harder it is to audit which Deny you actually need.
The script we are dissecting
# create a bucket
aws s3api create-bucket --bucket hello-bucket --region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
aws s3api put-bucket-acl --bucket hello-bucket --acl public-read
# upload a file to the bucket
aws s3 cp hello.txt s3://hello-bucket/hello.txt
# list the contents of the bucket
aws s3 ls s3://hello-bucket/
# delete the file from the bucket
aws s3 rm s3://hello-bucket/hello.txt
# download the file from the bucket
aws s3 cp s3://hello-bucket/hello.txt ./hello.txt
# delete the bucket
aws s3api delete-bucket --bucket hello-bucket
Seven commands. Seven IAM permission checks. Let us go through each one.
Command-to-permission map
| # | Command | IAM action required |
|---|---|---|
| 1 | aws s3api create-bucket |
s3:CreateBucket |
| 2 | aws s3api put-bucket-acl |
s3:PutBucketAcl |
| 3 |
aws s3 cp (upload) |
s3:PutObject |
| 4 | aws s3 ls |
s3:ListBucket |
| 5 | aws s3 rm |
s3:DeleteObject |
| 6 |
aws s3 cp (download) |
s3:GetObject |
| 7 | aws s3api delete-bucket |
s3:DeleteBucket |
One thing most beginners miss: S3 permissions split across two resource levels.
-
Bucket-level actions (
CreateBucket,ListBucket,PutBucketAcl,DeleteBucket) apply toarn:aws:s3:::bucket-name -
Object-level actions (
PutObject,GetObject,DeleteObject) apply toarn:aws:s3:::bucket-name/*
Getting this wrong is one of the most common sources of AccessDenied errors, even when the right action is listed.
The minimal IAM policy
Replace
hello-bucketwith your actual bucket name.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BucketLevelAccess",
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:ListBucket",
"s3:PutBucketAcl",
"s3:DeleteBucket"
],
"Resource": "arn:aws:s3:::hello-bucket"
},
{
"Sid": "ObjectLevelAccess",
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::hello-bucket/*"
}
]
}
No wildcards. No s3:*. Just the seven actions the script actually calls.
Line-by-line walkthrough
1. Create a bucket
aws s3api create-bucket --bucket hello-bucket --region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
-
IAM check:
s3:CreateBucketonarn:aws:s3:::hello-bucket - The
LocationConstraintis required for every region exceptus-east-1, which is the global default
2. Set a public ACL
aws s3api put-bucket-acl --bucket hello-bucket --acl public-read
-
IAM check:
s3:PutBucketAcl - ⚠️ 2026 note: AWS now enforces Object Ownership with ACLs disabled by default on new buckets. This command will throw
AccessControlListNotSupportedunless you explicitly set Object Ownership toBucketOwnerPreferredorObjectWriter. For demos, skip the ACL step entirely and use bucket policies if you need public access. - Public ACLs are also blocked by Block Public Access settings at the account level — double-check yours.
3. Upload an object
aws s3 cp hello.txt s3://hello-bucket/hello.txt
-
IAM check:
s3:PutObjectonarn:aws:s3:::hello-bucket/* - Make sure the local file exists before running the script
4. List objects
aws s3 ls s3://hello-bucket/
-
IAM check:
s3:ListBucketonarn:aws:s3:::hello-bucket - Note the resource is the bucket, not the objects — a common policy mistake
5. Delete an object
aws s3 rm s3://hello-bucket/hello.txt
-
IAM check:
s3:DeleteObjectonarn:aws:s3:::hello-bucket/*
6. Download an object
aws s3 cp s3://hello-bucket/hello.txt ./hello.txt
-
IAM check:
s3:GetObjectonarn:aws:s3:::hello-bucket/*
7. Delete the bucket
aws s3api delete-bucket --bucket hello-bucket
-
IAM check:
s3:DeleteBucketonarn:aws:s3:::hello-bucket - The bucket must be empty before this succeeds. S3 will return a
BucketNotEmptyerror otherwise.
A production-ready version of the script
The original script has a few rough edges worth smoothing out before using it in a real environment. The corrected version below uses a timestamp suffix to avoid S3's global naming conflicts, ensures the file is downloaded before it is deleted, and removes the ACL step that fails by default on modern AWS accounts.
#!/usr/bin/env bash
set -euo pipefail
BUCKET="hello-bucket-$(date +%s)"
REGION="eu-west-1"
LOCAL_FILE="test.txt"
OBJECT_KEY="hello.txt"
# 1. Create bucket
aws s3api create-bucket \
--bucket "$BUCKET" \
--region "$REGION" \
--create-bucket-configuration "LocationConstraint=$REGION"
# 2. Upload file
aws s3 cp "$LOCAL_FILE" "s3://$BUCKET/$OBJECT_KEY"
# 3. List objects
aws s3 ls "s3://$BUCKET/"
# 4. Download file (before deleting it)
aws s3 cp "s3://$BUCKET/$OBJECT_KEY" "./downloaded-$OBJECT_KEY"
# 5. Remove the object, then the bucket
aws s3 rm "s3://$BUCKET/$OBJECT_KEY"
aws s3api delete-bucket --bucket "$BUCKET"
The mental model I use before every AWS CLI command
Before writing or running any AWS CLI call, I ask three questions:
Which exact API action is this command calling?
Is the permission needed at the bucket level, object level, or both?
What is the narrowest resource ARN I can use?
That habit is what keeps IAM policies auditable. The moment you write s3:* or use "Resource": "*", you lose the ability to reason about what the policy actually permits.
Key takeaways
- Every AWS CLI command maps to at least one IAM action — knowing the mapping is the core skill
- S3 permissions split across two resource ARN patterns: bucket (
:::name) and objects (:::name/*) - Modern AWS accounts disable ACLs by default — do not rely on
put-bucket-aclin new scripts - Least privilege is not just a best practice; it is the only way to write policies you can actually audit
Top comments (0)