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.
by OhSudo ··
An error occurred (AccessDenied) when calling the GetObject operation: Access DeniedOr from head-object and many SDKs, with even less to go on:
An error occurred (403) when calling the HeadObject operation: ForbiddenS3 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.
- Who you areaws sts get-caller-identitythen
- Your IAM policyallows the action on that ARN?then
- The bucket policymay deny, or must allow other accountsthen
- KMS key, public access, CloudFrontthe layers after that(breaks here)
TL;DR
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 5aws s3api get-bucket-policy --bucket my-bucket --query Policy --output textNewer 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:
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:
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.csv4. 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 FoundGrant 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
aws s3api get-bucket-policy --bucket my-bucket --query Policy --output text | python3 -m json.toolLook for a Deny. These common rules can refuse you, whatever your own rights:
- HTTPS only:
"aws:SecureTransport": "false"refuses plainhttp://requests. - Network limits:
aws:SourceVpceoraws:SourceIpallows 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
PutObjectthat 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:
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:
aws s3api get-object-acl --bucket my-bucket --key reports/a.csv --query OwnerThe fix is to make the bucket own what’s put in it, which is the default for new buckets since April 2023:
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:
aws s3api get-public-access-block --bucket my-bucketThose 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:
aws s3 presign s3://my-bucket/reports/a.csv --expires-in 3600If 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.