AWS Identity and Access Management (IAM) evaluates permissions for each request. Permission to upload an S3 object doesn't include permission to download it. s3:PutObject and s3:GetObject are separate actions. A valid signature identifies the caller; it doesn't give that caller every permission.
In part 1 of AWS by Doing, an authenticated CLI request worked while an anonymous request failed. It's tempting to leave with a simple rule: sign the request and S3 lets you in.
This time, both requests used the same role. The upload worked. The download returned AccessDenied.
Then we'll change one permission and repeat the read. Finally, we'll leave that allow in place, add an explicit deny, and try again.
We ran this with AWS CLI 2.37.9 in Ohio, us-east-2, on October 5, 2026. The results below are from that run. The commands create new, disposable resources so you can repeat it.
Quick answer
For this experiment:
- Create a role that can upload one specific object in a private S3 bucket.
- Assume that role and verify which identity sends the requests.
- Upload the object, then attempt to read it and list the bucket.
- Add
s3:GetObjectfor that same object and repeat the read. - Add an explicit deny for
s3:GetObject, keeping the allow, and repeat it again.
The distinction to watch is no matching allow versus an explicit deny. In this isolated setup, adding the missing allow enables the read. An applicable explicit deny blocks it even when that allow exists. IAM policy evaluation
A role needs two different policies
A role's trust policy controls who can assume it. Its permissions policy controls what the resulting role session can do.
They answer different questions. Trusting your development identity to assume the lab role doesn't automatically give the lab role access to S3. Likewise, giving the role s3:PutObject doesn't establish who can assume it. IAM policies and roles
We'll use our normal development identity to create the resources and change the lab role's policy. The upload and read tests use the lab role's temporary credentials. Keeping those identities separate is the whole experiment. Testing with the setup identity would tell us nothing about the lab role.
Prerequisites
- AWS CLI v2, Python 3, and a Bash or Zsh terminal with
uuidgenanddiff. - An authenticated IAM user or role in a development account, using your existing temporary credentials.
- Setup permissions to create and delete a bucket, configure its public-access blocks, manage the dedicated role and its inline policy, assume that role, and read and delete the test object.
- A trusted IAM user or role ARN for that development identity. The commands below use the standard AWS partition and Ohio,
us-east-2.
Use your existing login or SSO profile. If you use a named profile, select it with export AWS_PROFILE=your-profile-name before starting. Don't create long-lived access keys for this lab. If the CLI isn't authenticated, complete the sign-in method from part 1 first. Keep shell tracing and AWS debug logging off, and redact identity identifiers before sharing output.
This uses one small S3 object and a few requests. Check your account's billing plan; don't assume the experiment is free. Cleanup is included.
Step 1: Define the lab and its trusted identity
Run the blocks in the same terminal session. Stop on an unexpected setup error before continuing. Some later commands deliberately fail; run those individually in an interactive shell rather than a script configured to exit on every error.
set +x
aws sts get-caller-identity --no-cli-pager
LAB_REGION=us-east-2
LAB_SUFFIX=$(uuidgen | tr '[:upper:]' '[:lower:]')
LAB_BUCKET="ravi-aws-iam-lab-$LAB_SUFFIX"
LAB_ROLE="ravi-aws-iam-lab-$LAB_SUFFIX"
LAB_KEY=notes/hello.txt
LAB_POLICY=LabObjectAccess
LAB_DIR=$(mktemp -d)
LAB_ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
LAB_ROLE_ARN="arn:aws:iam::${LAB_ACCOUNT}:role/${LAB_ROLE}"
# Replace this with the existing IAM user or role that will assume the lab role.
LAB_SOURCE_ARN='arn:aws:iam::123456789012:role/YourDevelopmentRole'The source ARN is an IAM identity ARN, not the STS session ARN printed by get-caller-identity. If the caller is an assumed role, use that role's IAM ARN, including its path. Confirm it with aws iam get-role --role-name YOUR_ROLE_NAME --query Role.Arn --output text. Don't guess the path or copy the session ARN into the trust policy.
Replace LAB_SOURCE_ARN before proceeding and check that it belongs to the same account. This walkthrough trusts that one IAM identity directly. For a same-account IAM user, the trust policy can grant assumption without a separate identity-policy allow; applicable restrictions and explicit denies still matter. If assumption fails, check the caller, trust, and policy restrictions together. AssumeRole permissions
Step 2: Create a private bucket and an upload-only role
aws s3 mb "s3://$LAB_BUCKET" --region "$LAB_REGION"
aws s3api put-public-access-block \
--bucket "$LAB_BUCKET" --region "$LAB_REGION" \
--public-access-block-configuration \
'BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true'
aws s3api get-public-access-block \
--bucket "$LAB_BUCKET" --region "$LAB_REGION" --no-cli-pagerCheck that all four values are true. Leave the bucket policy and ACLs alone. This lab uses a same-account role with an identity policy and no additional resource-based access grants.
Before generating the files, here's the upload-only permissions policy we'll attach to the role. YOUR_LAB_BUCKET is illustrative; the generator substitutes the bucket you just created.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowLabObject",
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::YOUR_LAB_BUCKET/notes/hello.txt"
}]
}Read the three consequential fields together: allow this action on this resource. There's no GetObject grant hiding inside PutObject.
Generate the trust policy and all three permissions policies locally:
python3 - "$LAB_DIR" "$LAB_SOURCE_ARN" "$LAB_BUCKET" "$LAB_KEY" <<'PY'
import json
import sys
from pathlib import Path
directory, source, bucket, key = sys.argv[1:]
directory = Path(directory)
trust = {
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": source},
"Action": "sts:AssumeRole"
}]
}
(directory / "trust.json").write_text(json.dumps(trust, indent=2))
resource = f"arn:aws:s3:::{bucket}/{key}"
for name, actions in [
("upload-only", ["s3:PutObject"]),
("read-write", ["s3:PutObject", "s3:GetObject"])
]:
policy = {
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowLabObject",
"Effect": "Allow",
"Action": actions,
"Resource": resource
}]
}
(directory / f"{name}.json").write_text(json.dumps(policy, indent=2))
policy["Statement"].append({
"Sid": "DenyReadingLabObject",
"Effect": "Deny",
"Action": "s3:GetObject",
"Resource": resource
})
(directory / "explicit-deny.json").write_text(json.dumps(policy, indent=2))
PY
aws iam create-role --role-name "$LAB_ROLE" \
--assume-role-policy-document "file://$LAB_DIR/trust.json" \
--query Role.Arn --output text --no-cli-pager
aws iam put-role-policy --role-name "$LAB_ROLE" \
--policy-name "$LAB_POLICY" \
--policy-document "file://$LAB_DIR/upload-only.json"The resource is the object ARN, including /notes/hello.txt. A bucket ARN alone isn't the resource for s3:PutObject. The Version field identifies the policy language, not today's date. S3 operation permissions
IAM changes can take time to propagate. If role assumption initially fails, inspect the caller and trust policy, allow time for propagation, and retry. Don't add wildcard permissions to work around a delay. This is different from the strong consistency of S3 object writes we used in part 1. IAM consistency
Step 3: Run requests as the lab role
AssumeRole returns an access key, secret key, and session token. Printing its default response would expose credentials. We'll capture that response inside a small Python helper and supply the credentials only to its child AWS CLI process.
cat > "$LAB_DIR/run-as-lab.py" <<'PY'
import json
import os
import subprocess
import sys
role_arn, region, *arguments = sys.argv[1:]
assumed = subprocess.run(
["aws", "sts", "assume-role", "--role-arn", role_arn,
"--role-session-name", "aws-by-doing-02", "--duration-seconds", "900",
"--region", region, "--query", "Credentials", "--output", "json",
"--no-cli-pager"],
capture_output=True, text=True
)
if assumed.returncode:
sys.stderr.write(assumed.stderr)
sys.exit(assumed.returncode)
credentials = json.loads(assumed.stdout)
environment = os.environ.copy()
environment.pop("AWS_PROFILE", None)
environment.pop("AWS_DEFAULT_PROFILE", None)
environment.update({
"AWS_ACCESS_KEY_ID": credentials["AccessKeyId"],
"AWS_SECRET_ACCESS_KEY": credentials["SecretAccessKey"],
"AWS_SESSION_TOKEN": credentials["SessionToken"],
"AWS_PAGER": ""
})
result = subprocess.run(
["aws", *arguments, "--region", region, "--no-cli-pager"],
env=environment
)
sys.exit(result.returncode)
PY
lab_aws() {
python3 "$LAB_DIR/run-as-lab.py" "$LAB_ROLE_ARN" "$LAB_REGION" "$@"
}
lab_aws sts get-caller-identity --query Arn --output textThe ARN should identify the lab role's assumed session. If it identifies your setup role, stop: the permission tests would be using the wrong identity.
The helper requests a new 15-minute session for each invocation. It doesn't print the STS credential response or save it to a file. Your setup credentials stay in the parent shell; the child process gets the lab credentials. Use it only for the commands shown here; don't add a profile override, credential inspection, or debug flags. Temporary credential usage
Step 4: Upload the object, then try to read it
printf 'Hello from AWS by Doing.\n' > "$LAB_DIR/hello.txt"
lab_aws s3api put-object \
--bucket "$LAB_BUCKET" --key "$LAB_KEY" \
--body "$LAB_DIR/hello.txt" --content-type text/plain \
--server-side-encryption AES256
lab_aws s3api get-object \
--bucket "$LAB_BUCKET" --key "$LAB_KEY" "$LAB_DIR/denied-read.txt"
lab_aws s3api list-objects-v2 \
--bucket "$LAB_BUCKET" --query 'Contents[].Key'The upload succeeded. The download and listing both failed with AccessDenied.
The useful part of the download error was its explanation. With the identity and resource identifiers omitted, it said:
because no identity-based policy allows the s3:GetObject actionThe listing error gave the same explanation for s3:ListBucket.
These are separate requests with separate required actions. PutObject uses s3:PutObject, reading uses s3:GetObject, and ListObjectsV2 uses s3:ListBucket on the bucket ARN. Our policy only allows the first. An upload is no promise that the same identity can inspect or retrieve what it wrote.
These were implicit denies: there was no applicable allow for reading or listing. The errors matched the policy we attached. Error wording can vary with the account and policy context; an AccessDenied response alone doesn't establish which policy caused it. S3 access-denied troubleshooting
To confirm the upload independently, use the setup identity to inspect the object:
aws s3api head-object \
--bucket "$LAB_BUCKET" --key "$LAB_KEY" --region "$LAB_REGION" \
--query '{Bytes:ContentLength,Encryption:ServerSideEncryption}' \
--no-cli-pagerOur result:
{
"Bytes": 25,
"Encryption": "AES256"
}The object existed. Its bytes were stored with SSE-S3 encryption. The lab role just wasn't allowed to read them.
Step 5: Add the missing read permission
Replace the role's inline policy using the setup identity:
aws iam put-role-policy --role-name "$LAB_ROLE" \
--policy-name "$LAB_POLICY" \
--policy-document "file://$LAB_DIR/read-write.json"The first read immediately after this update still failed with the missing-allow message. That was the surprise: the change had been accepted, but the request hadn't picked it up yet.
We inspected the stored policy:
aws iam get-role-policy --role-name "$LAB_ROLE" \
--policy-name "$LAB_POLICY" \
--query PolicyDocument --output json --no-cli-pagerIt contained both s3:PutObject and s3:GetObject for our object. A later retry downloaded the file successfully:
lab_aws s3api get-object \
--bucket "$LAB_BUCKET" --key "$LAB_KEY" "$LAB_DIR/allowed-read.txt" &&
diff "$LAB_DIR/hello.txt" "$LAB_DIR/allowed-read.txt"
lab_aws s3api list-objects-v2 \
--bucket "$LAB_BUCKET" --query 'Contents[].Key'The byte comparison passed. With diff, no output means the files match. If your first read still fails, verify the policy, allow time for IAM propagation, and retry before changing permissions again. A successful policy update doesn't guarantee every endpoint already sees it.
We added s3:GetObject for the existing object. We didn't change its bytes, make the bucket public, or grant access to every object in the account.
Listing still failed. Reading a known key doesn't require permission to browse the bucket. That distinction matters for applications that already know which file they're requesting.
Step 6: Keep the allow, add an explicit deny
The final policy retains the read allow and adds this statement for the same object:
{
"Sid": "DenyReadingLabObject",
"Effect": "Deny",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_LAB_BUCKET/notes/hello.txt"
}The generated file already contains your real lab bucket name. Apply it and repeat the read:
aws iam put-role-policy --role-name "$LAB_ROLE" \
--policy-name "$LAB_POLICY" \
--policy-document "file://$LAB_DIR/explicit-deny.json"
lab_aws s3api get-object \
--bucket "$LAB_BUCKET" --key "$LAB_KEY" "$LAB_DIR/explicit-denied-read.txt"
lab_aws s3api put-object \
--bucket "$LAB_BUCKET" --key "$LAB_KEY" \
--body "$LAB_DIR/hello.txt" --content-type text/plain \
--server-side-encryption AES256The read failed again. This time, the error explanation changed:
with an explicit deny in an identity-based policyThe upload still worked. We inspected the final policy and confirmed that the read allow remained alongside the deny. The allow hadn't disappeared. It lost to the applicable explicit deny, which targeted GetObject alone.
Policy evaluation isn't a sequence where the last statement wins. Reordering these statements doesn't make the read work. When debugging a real denial, check for applicable denies rather than repeatedly adding more allows.
Same AccessDenied, different permission problem
Both failed reads used AccessDenied. The first needed an allow that was missing. The final read had an allow, but an explicit deny overrode it. Adding another allow would leave that denial in place.
Here's what we observed after the policy changes propagated:
| Role policy | Upload the named object | Read the named object | List the bucket |
|---|---|---|---|
| PutObject only | Succeeded | Denied: no identity-based allow | Denied: no ListBucket allow |
| PutObject + GetObject | Not repeated in this stage | Succeeded, matching bytes | Denied: no ListBucket allow |
| Same allow + explicit GetObject deny | Succeeded | Denied: explicit deny | Denied: no ListBucket allow |
All four public-access blocks stayed enabled. The role's permissions changed; the bucket's public-access settings didn't.
The table describes this isolated setup. Resource policies, session policies, permissions boundaries, organization controls, and KMS permissions can affect a real request. We used SSE-S3 to keep a customer-managed KMS key's permissions out of the experiment.
Why a presigned URL can't fix missing read permission
Part 1 used a presigned URL to give a recipient temporary access. It didn't create a new permission for the signer. A GET URL signed by an upload-only role can't turn s3:PutObject into s3:GetObject.
The signer still needs permission for the requested operation. A URL being generated isn't evidence that S3 will accept it. Presigned URL permissions
Delete the lab resources
Use the setup identity for cleanup. The lab role doesn't have delete permission.
aws s3api get-public-access-block \
--bucket "$LAB_BUCKET" --region "$LAB_REGION" --no-cli-pager
aws s3api delete-object \
--bucket "$LAB_BUCKET" --key "$LAB_KEY" \
--region "$LAB_REGION" --no-cli-pager
aws s3 rb "s3://$LAB_BUCKET" --region "$LAB_REGION"
aws iam delete-role-policy \
--role-name "$LAB_ROLE" --policy-name "$LAB_POLICY"
aws iam delete-role --role-name "$LAB_ROLE"
unset -f lab_awsCheck that bucket deletion succeeds, then confirm the role was removed:
aws iam get-role --role-name "$LAB_ROLE" --query Role.Arn --output textOur bucket deletion succeeded, and get-role returned NoSuchEntity. A subsequent bucket check returned 404, Not Found. The test object, bucket, inline policy, and role were removed.
After successful role deletion, expect NoSuchEntity. A permission error isn't confirmation of deletion. If cleanup fails, inspect the failure; don't use force deletion on an unexpected bucket or remove unrelated policies. The small local files remain in LAB_DIR. Actual billed cost has not been measured. S3 pricing
The useful question behind AccessDenied
For an AWS permission failure, identify which principal requested which action on which resource. Then ask whether an applicable allow is missing or a deny is overriding it.
“But the upload worked” only answers a question about s3:PutObject. It doesn't answer the question your failed download asked.
Try that sequence on your next denied request: caller, action, resource, then the applicable policies. It gives you a specific permission to investigate before you reach for s3:*.
Further reading
- IAM policy evaluation: how applicable allows and denies interact.
- IAM policies and permissions: trust policies, identity policies, and other policy types.
- S3 operation permissions: mapping API operations to actions and resource types.
- AssumeRole CLI reference: temporary sessions and their duration.