1. Introduction
An S3 403 Access Denied is deceptively simple: the request reached S3, S3 understood it, and S3 refused it. The hard part is that "denied" can originate from at least five independent authorization layers — the caller's IAM policy, the bucket policy, S3 Block Public Access, Object Ownership/ACLs, the KMS key policy, and (for private access) a VPC endpoint policy. Any one of them can veto the request, and the error message rarely tells you which.
This guide gives you a deterministic order to check those layers, the exact CLI commands to inspect each one, and the fixes for the cases that cause the overwhelming majority of production 403s — especially the ones that appear after enabling encryption or turning on Block Public Access.
2. What a 403 Actually Tells You
S3 evaluates every request against the union of all applicable policies. The decision logic is: an explicit Deny anywhere always wins; otherwise you need an explicit Allow from an identity-based or resource-based policy. No matching Allow = implicit deny = 403.
AccessDeniedonGetObject/PutObject— most often IAM or bucket policy, or a KMS permission gap on an encrypted objectAccessDeniedonListBucket— thes3:ListBucketaction is on the bucket ARN, not the object ARN; a very common policy mistake403when made public — Block Public Access is overriding your bucket policy/ACLAccessDeniedright after enabling SSE-KMS — the caller lackskms:Decrypt/kms:GenerateDataKeyon the key
3. Common Causes
- IAM policy is missing the action, or scopes it to the wrong ARN (bucket vs object)
- Bucket policy contains an explicit
Deny(often "deny if not using TLS" or "deny if wrong VPC/account") - S3 Block Public Access is on and is overriding a public bucket policy or ACL
- Object was uploaded by another account and, under Object Ownership, ACLs still grant ownership to the uploader
- The bucket uses SSE-KMS and the principal lacks
kms:Decryptorkms:GenerateDataKeyon the key - Requests go through a VPC gateway endpoint whose endpoint policy restricts buckets/actions
- An SCP at the AWS Organizations level denies the action regardless of IAM
- Cross-account access without both an IAM allow (caller side) and a bucket policy allow (resource side)
4. Step-by-Step Diagnosis and Fix
Step 1: Confirm who is actually calling
# Which identity is the request using? (role vs user matters)
aws sts get-caller-identity
# Reproduce the exact failing call with debug to see the request/response
aws s3api get-object --bucket my-bucket --key path/to/key /tmp/out --debug 2>&1 | tail -40
Step 2: Simulate the policy decision (fastest root-cause tool)
# IAM Policy Simulator via CLI — tells you Allow/Deny and WHICH policy decided
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/app-role \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::my-bucket/path/to/key
Step 3: Verify the IAM policy uses the correct ARNs
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*" // OBJECT actions -> /*
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::my-bucket" // LIST -> bucket ARN
}
]
}
Step 4: Inspect the bucket policy for explicit Deny
aws s3api get-bucket-policy --bucket my-bucket \
--query Policy --output text | python -m json.tool
# Watch for a common TLS-only deny that 403s any non-HTTPS request:
# "Effect": "Deny", "Condition": {"Bool": {"aws:SecureTransport": "false"}}
# and account/VPC restrictions like aws:SourceVpce or aws:PrincipalAccount.
Step 5: Check Block Public Access and Object Ownership
# Block Public Access overrides public policies/ACLs when true
aws s3api get-public-access-block --bucket my-bucket
# Object Ownership — "BucketOwnerEnforced" disables ACLs entirely
aws s3api get-bucket-ownership-controls --bucket my-bucket
# If a cross-account upload left ownership with the uploader:
aws s3api get-object-acl --bucket my-bucket --key path/to/key
Step 6: Fix KMS permissions for SSE-KMS buckets
If the bucket enforces SSE-KMS, S3 access is not enough — the caller also needs key permissions. A 403 that started right after enabling encryption is almost always this.
# Find the bucket's default KMS key
aws s3api get-bucket-encryption --bucket my-bucket
# The principal needs these on the KMS key (via key policy or IAM):
# kms:Decrypt (GetObject)
# kms:GenerateDataKey (PutObject)
{
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource": "arn:aws:kms:us-east-1:111122223333:key/KEY-ID"
}
5. Verification Steps
# List (needs s3:ListBucket on the bucket ARN)
aws s3 ls s3://my-bucket/path/
# Download (needs s3:GetObject on the object ARN + kms:Decrypt if SSE-KMS)
aws s3 cp s3://my-bucket/path/to/key /tmp/ok
# Upload (needs s3:PutObject + kms:GenerateDataKey if SSE-KMS)
echo test | aws s3 cp - s3://my-bucket/path/verify.txt
# Re-run the simulator and confirm it now returns "allowed"
aws iam simulate-principal-policy --policy-source-arn <role-arn> \
--action-names s3:GetObject --resource-arns arn:aws:s3:::my-bucket/path/to/key
6. Common Mistakes
- Putting
s3:ListBucketon the object ARN (/*) instead of the bucket ARN - Adding S3 permissions but forgetting KMS permissions on an SSE-KMS bucket
- Assuming a bucket is public because the policy says so — Block Public Access silently overrides it
- Debugging the wrong identity: an EC2/EKS workload uses its instance/IRSA role, not your CLI user
- Ignoring an explicit
Denyin the bucket policy or an Organizations SCP, which no IAM Allow can override
7. Prevention Tips
- Use the IAM Policy Simulator (or
aws s3api ... --dryrunpatterns) in CI before shipping policy changes - Standardize on
BucketOwnerEnforcedObject Ownership to eliminate ACL-based 403s entirely - Grant KMS key permissions in the same change that enables SSE-KMS, not after the first failure
- For workloads on EKS, prefer IRSA so each pod has a scoped role instead of node-wide credentials
- Related EKS/IAM denials that aren't S3-specific are covered in Fix AWS AccessDenied (IAM & EKS)
8. FAQ
I can download objects but "aws s3 ls" returns Access Denied. Why?
Listing uses the s3:ListBucket action, which must be granted on the bucket ARN (arn:aws:s3:::my-bucket), not the object ARN. s3:GetObject is granted on the object ARN (arn:aws:s3:::my-bucket/*). Having one without the other produces exactly this "download works, list fails" pattern.
Access started failing right after I enabled default encryption. What changed?
The bucket is now SSE-KMS, so every read needs kms:Decrypt and every write needs kms:GenerateDataKey on the KMS key — in addition to the S3 permissions. Add those actions to the caller via the key policy or its IAM policy. S3 permissions alone will keep returning 403.
My bucket policy allows public read but objects are still 403. Why?
S3 Block Public Access is almost certainly enabled and overriding the policy. Check aws s3api get-public-access-block. If IgnorePublicAcls or RestrictPublicBuckets is true, the public grant is ignored. For genuinely public content, front the bucket with CloudFront + OAC instead of disabling Block Public Access.
9. Summary
| Symptom | Cause | Fix |
|---|---|---|
| GetObject 403, ListBucket ok | Missing action or wrong ARN | Object action on bucket/* |
| ListBucket 403, GetObject ok | List on object ARN | s3:ListBucket on bucket ARN |
| 403 after enabling SSE-KMS | No KMS key permission | Add kms:Decrypt/GenerateDataKey |
| Public policy ignored | Block Public Access on | Use CloudFront OAC, review BPA |
| Cross-account 403 | Only one side allows | Allow on both IAM and bucket policy |
Explore More in This Category
Explore more in this category: AWS & Cloud guides. Browse all DevOps Compass articles or jump to: Kubernetes, AWS, CI/CD, Containers, Monitoring, Networking.