S3 denies by default, and four independent mechanisms can each block a request. An allow in one does not override a deny in another, so the question is always which one said no.
| Mechanism | Can it deny? |
|---|---|
| IAM identity policy | Yes, by absence or explicit Deny |
| Bucket policy | Yes |
| Block Public Access | Yes, overrides policies granting public access |
| SCP or permissions boundary | Yes, explicit Deny wins over everything |
| KMS key policy | Yes, for encrypted objects |
Start with who you actually are
aws sts get-caller-identity
{
"UserId": "AROA...:i-0abc123",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/app-role/i-0abc123"
}
Note the ARN is sts::...:assumed-role/app-role/..., not iam::...:role/app-role. A bucket policy Principal written as the assumed-role ARN with the session name will not match reliably. Use the role ARN in policies and the assumed-role ARN only when you know why.
Let IAM answer instead of reading policies
The simulator evaluates the real policy set:
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/app-role \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::acme-assets/logs/app.log
"EvalDecision": "implicitDeny"
| Decision | Meaning |
|---|---|
allowed | Identity policy permits it. Look at bucket policy, BPA or KMS |
implicitDeny | Nothing grants it. Add the permission |
explicitDeny | Something actively denies. SCP, boundary, or a Deny statement |
This is far faster than reading four documents and simulating the logic in your head.
The resource ARN trap
The single most common IAM mistake with S3:
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::acme-assets"
}
That grants nothing. Object actions need the object ARN with a key path; bucket actions need the bucket ARN. They are different resources:
{
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::acme-assets/*"
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket", "s3:GetBucketLocation"],
"Resource": "arn:aws:s3:::acme-assets"
}
]
}
s3:ListBucket on the bucket is needed for aws s3 ls and for aws s3 sync. Without it, sync fails in a way that looks like an object permission problem.
ListBucket also changes the error you get
If you have s3:GetObject but not s3:ListBucket, a request for a missing key returns AccessDenied rather than NoSuchKey. S3 does this deliberately so that a caller cannot probe which keys exist. So an AccessDenied on a path you believe should work may simply mean the object is not there.
Granting s3:ListBucket makes the real error appear.
Bucket policies
aws s3api get-bucket-policy --bucket acme-assets --query Policy --output text | python3 -m json.tool
Look for "Effect": "Deny". A common one enforcing TLS:
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": ["arn:aws:s3:::acme-assets", "arn:aws:s3:::acme-assets/*"],
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
}
That denies plain HTTP. An SDK configured with an HTTP endpoint hits it and reports a generic Access Denied.
Another frequent one requires a specific encryption header on upload:
"Condition": { "StringNotEquals": { "s3:x-amz-server-side-encryption": "aws:kms" } }
Your PutObject then has to send that header.
Block Public Access
aws s3api get-public-access-block --bucket acme-assets
All four settings default to true on buckets created since 2023. BPA overrides any policy or ACL that would grant public access, so a bucket policy with "Principal": "*" is simply ignored.
If you want public reads, CloudFront with Origin Access Control is the better pattern than disabling BPA: the bucket stays private and only CloudFront can read it.
KMS-encrypted objects
An object encrypted with a customer-managed key needs both S3 and KMS permission:
{
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource": "arn:aws:kms:eu-west-1:123456789012:key/1234abcd-..."
}
And the key policy must allow the principal too. A KMS key policy is not optional the way a bucket policy is: without a grant there, IAM permissions alone are not enough.
This is the cause when GetObject is denied on one bucket while working on another with identical IAM.
Cross-account
Both sides must allow it. The bucket policy grants the other account's role, and that role's IAM policy must also permit the action.
Also set object ownership, or objects written by the other account stay owned by it and your own account cannot read them:
aws s3api put-bucket-ownership-controls --bucket acme-assets \
--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
BucketOwnerEnforced disables ACLs entirely and makes the bucket owner own everything, which removes a whole class of confusing cross-account failures.
A checklist
aws sts get-caller-identity. Policies must match this identity.aws iam simulate-principal-policybefore reading any policy by hand.implicitDeny→ the identity policy is missing the permission.- Object actions need
arn:aws:s3:::bucket/*; bucket actions needarn:aws:s3:::bucket. - Add
s3:ListBucket, or a missing key reports AccessDenied instead of NoSuchKey. - Check the bucket policy for
Denyonaws:SecureTransportor an encryption header. - Public access → Block Public Access overrides policies. Prefer CloudFront with OAC.
- Encrypted objects → KMS permission in IAM and in the key policy.
Frequently Asked Questions
Why does S3 return AccessDenied instead of NoSuchKey for a missing object?
Because without s3:ListBucket permission, telling you the object does not exist would leak information about the bucket's contents. S3 therefore returns AccessDenied for both a forbidden object and a missing one, so an unauthorised caller cannot probe which keys exist. Granting s3:ListBucket on the bucket ARN changes the behaviour and you start getting the real NoSuchKey error, which is worth doing in any environment where you are debugging paths.
What is the difference between a bucket policy and an IAM policy?
An IAM policy is attached to an identity and says what that identity may do. A bucket policy is attached to the bucket and says who may do what to it. Both are evaluated, and a request is allowed only if nothing denies it and at least one of them allows it. For same-account access an IAM policy alone is enough. For cross-account access both are required: the bucket policy must grant the external principal, and that principal's own IAM policy must also permit the action.
Why does my bucket policy granting public access not work?
Block Public Access is almost certainly enabled, and it overrides bucket policies and ACLs that would grant public access. All four of its settings default to on for buckets created since 2023. You can disable it, and a better pattern for serving public content is CloudFront with Origin Access Control: the bucket stays fully private, only the CloudFront distribution can read it, and you gain caching and TLS termination at the edge.
Why is GetObject denied on one bucket but not another with the same IAM policy?
The most likely difference is encryption with a customer-managed KMS key. Objects encrypted that way need kms:Decrypt in the caller's IAM policy and a grant for that principal in the KMS key's own key policy. Unlike a bucket policy, the key policy is not optional: IAM permissions alone cannot grant access to a KMS key that does not allow the principal. Check which key the bucket uses with aws s3api get-bucket-encryption.
How do I debug this without reading four policy documents?
Use aws iam simulate-principal-policy with the role ARN, the action and the exact resource ARN. It evaluates the real identity policies and returns allowed, implicitDeny or explicitDeny, which immediately tells you whether the problem is a missing grant or an active denial from an SCP, permissions boundary or Deny statement. If it returns allowed and the call still fails, you have narrowed the cause to the bucket policy, Block Public Access or KMS.