Skip to content
ohsudo, Home
Cloud

AWS S3 "403 Access Denied": a checklist for every layer that can refuse you

Fix AWS S3 "403 Access Denied": check your identity, IAM and bucket policies, ListBucket, Block Public Access, KMS keys, cross-account ownership and CloudFront, in order.

Intermediate5 min readApplies to Amazon S3 · AWS CLI v2 · IAM · AWS KMS · CloudFront

by OhSudo ··

An error occurred (AccessDenied) when calling the GetObject operation: Access Denied

Or from head-object and many SDKs, with even less to go on:

An error occurred (403) when calling the HeadObject operation: Forbidden

S3 has more layers of permissions than almost any AWS service, and any one of them can refuse you with the same three words. An explicit Deny anywhere beats every Allow, and for some of the layers, you need an Allow in more than one place.

So don’t change anything yet. Go through them in order, as most cases end within the first three.

  1. Who you areaws sts get-caller-identity
  2. Your IAM policyallows the action on that ARN?
  3. The bucket policymay deny, or must allow other accounts
  4. KMS key, public access, CloudFrontthe layers after that(breaks here)
S3 has several layers that can each refuse a request with the same Access Denied. An explicit Deny anywhere beats every Allow, so go through them in order.

TL;DR

Terminal window
aws sts get-caller-identity # who am I really?
aws s3api head-object --bucket my-bucket --key reports/a.csv # can I read this one?
aws s3api list-objects-v2 --bucket my-bucket --prefix reports/ --max-items 5
aws s3api get-bucket-policy --bucket my-bucket --query Policy --output text

Newer error messages often name the reason at the end: because no identity-based policy allows or with an explicit deny. Read the whole message.

1. Who are you?

More often than you’d think, the request runs as someone else:

Terminal window
aws sts get-caller-identity
{ "Account": "123456789012", "Arn": "arn:aws:iam::123456789012:user/ci-deploy" }

Is this the account and role you expected? Credentials can come from environment variables, a profile or an instance role, and environment variables win. A wrong profile or an old AWS_ACCESS_KEY_ID explains many mysteries. See not authorized to perform for how to read a policy error in general.

2. Read the message

Recent messages can tell you which layer refused:

User: arn:aws:iam::123456789012:user/ci-deploy is not authorized to perform: s3:GetObject on resource: "arn:aws:s3:::my-bucket/reports/a.csv" because no identity-based policy allows the s3:GetObject action
The end of the message says Look at
no identity-based policy allows The policies on your user or role (section 3)
no resource-based policy allows The bucket policy
with an explicit deny in a resource-based policy A Deny in the bucket policy (section 5)
with an explicit deny in an identity-based policy A Deny on your user or role
with an explicit deny in a service control policy An organization-wide rule: ask your AWS admin

Requests made anonymously or from another account often just get the bare Access Denied.

3. The policy: two kinds of ARN

This is the classic mistake. Bucket actions and object actions have different resources, so a policy needs both:

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-bucket"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}

s3:ListBucket on arn:aws:s3:::my-bucket/* or s3:GetObject on arn:aws:s3:::my-bucket matches nothing, so it’s denied, even though the policy looks right.

To test a policy without touching anything:

Terminal window
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:user/ci-deploy \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::my-bucket/reports/a.csv

4. A missing file can also be a 403

If you don’t have s3:ListBucket, S3 won’t tell you whether an object exists. Asking for a key that isn’t there gives 403, not 404. So check the name carefully: a wrong path, wrong case, or a key starting with /.

With s3:ListBucket you’d get a clear answer:

An error occurred (404) when calling the HeadObject operation: Not Found

Grant it, and many “permission” problems turn out to be typos in the key. The same goes for a CloudFront site whose missing pages give 403.

5. The bucket policy

Terminal window
aws s3api get-bucket-policy --bucket my-bucket --query Policy --output text | python3 -m json.tool

Look for a Deny. These common rules can refuse you, whatever your own rights:

  • HTTPS only: "aws:SecureTransport": "false" refuses plain http:// requests.
  • Network limits: aws:SourceVpce or aws:SourceIp allows access only through one VPC endpoint or address. It’s usually why it works from an EC2 instance, and not from your laptop.
  • Encryption rules: a PutObject that doesn’t use the required encryption is refused. Add --sse aws:kms.

If you’ve locked out even your admin with a broad Deny, the account’s root user can delete the policy.

6. Encrypted with KMS

The object may use a KMS key. Then S3 permission isn’t enough: you also need permission on the key. See whether that’s the case:

Terminal window
aws s3api head-object --bucket my-bucket --key reports/a.csv --query '[ServerSideEncryption,SSEKMSKeyId]'

If it says aws:kms, your role needs this on that key:

{
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource": "arn:aws:kms:eu-central-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab"
}

The key’s own policy has to allow it as well. The default aws/s3 key can’t be shared across accounts, which is a common trap: use a customer managed key for that.

7. Across accounts

Access across accounts needs an Allow on both sides: the bucket policy in the owner’s account, and an IAM policy in yours. A bucket policy alone isn’t enough.

Objects uploaded by another account may also still belong to that account, even in your bucket. Check the owner:

Terminal window
aws s3api get-object-acl --bucket my-bucket --key reports/a.csv --query Owner

The fix is to make the bucket own what’s put in it, which is the default for new buckets since April 2023:

Terminal window
aws s3api put-bucket-ownership-controls --bucket my-bucket \
--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'

8. Public access and CloudFront

A browser getting an XML page with AccessDenied is asking without credentials, so the object has to be public, and by default it isn’t:

Terminal window
aws s3api get-public-access-block --bucket my-bucket

Those settings exist on the bucket and on the account, and can override any policy. Before you turn them off, ask whether it should be public at all. To serve a site, keep the bucket private and use CloudFront with Origin Access Control. To share a single file, use a link that expires:

Terminal window
aws s3 presign s3://my-bucket/reports/a.csv --expires-in 3600

If you’re using CloudFront, its bucket policy must allow cloudfront.amazonaws.com with your distribution’s ARN. A missing default root object also gives 403 on the home page.

Warning

Don’t fix this with "Principal": "*" or s3:* on a bucket that holds private data. Open buckets are one of the most common ways company data leaks. Find which layer refused, and allow exactly what’s needed there.

Shell, we have a problem!

Similar, but different: SignatureDoesNotMatch means wrong keys or a tampered request. AccessDenied: Request has expired is a presigned link past its time. ExpiredToken means temporary credentials ran out. PermanentRedirect (301) means the bucket is in another region, so set --region. For the same error on other services, see not authorized to perform.

Found this useful? Share it.

Found a mistake? Let us know at contact@ohsudo.com.

Articles

Related articles