AWS S3 Permissions

By Vishvesh Patel · DevOps EngineerUpdated September 12, 2026

How S3 access is actually decided. IAM versus bucket policies, Block Public Access, ACLs being disabled, cross-account access, and debugging AccessDenied.

S3 access is decided by several mechanisms evaluated together, and an AccessDenied never tells you which one refused. Knowing the order turns a guessing game into a checklist.

The mechanisms

MechanismAttached toUse for
IAM policyA user or role"What may this principal do?"
Bucket policyThe bucket"Who may access this bucket?"
Block Public AccessAccount and bucketA hard override preventing public access
ACLsBucket and objectsLegacy: disabled by default. Do not use
VPC endpoint policyThe endpointRestricting access through a gateway endpoint

Within one account, an IAM policy alone is sufficient. You do not need a bucket policy for your own principals. Bucket policies come into their own for cross-account access, for enforcing conditions on everyone regardless of their IAM, and for the narrow cases of genuine public read.

How a request is evaluated

  1. Block Public Access, if it would make the request public and BPA is on, denied. This overrides everything, including an explicit allow in a bucket policy.
  2. Explicit Deny in any policy. Denied immediately.
  3. An Allow in an IAM policy or the bucket policy. Permitted.
  4. Otherwise. Denied by default.

The consequence worth internalising: Block Public Access wins over a bucket policy that grants public read. A team that adds a public-read policy and still gets 403 is nearly always hitting this, and the fix is not to disable BPA. It is to put CloudFront in front.

Bucket and object are different resources

This is the most common S3 policy mistake by a distance.

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

s3:ListBucket acts on the bucket ARN with no trailing /*. s3:GetObject acts on the object ARN, which requires it. Writing one statement with both actions against one ARN fails in a way that looks arbitrary: listing works but downloads do not, or vice versa.

A related confusion: a missing s3:ListBucket makes a GetObject on a non-existent key return 403 rather than 404, because S3 will not confirm whether an object exists to a principal that cannot list. People chase a permissions bug when the real problem is a typo in the key.

Block Public Access

Four independent settings, all on by default on new buckets:

SettingBlocks
BlockPublicAclsNew public ACLs
IgnorePublicAclsExisting public ACLs
BlockPublicPolicyNew public bucket policies
RestrictPublicBucketsPublic and cross-account access via existing policies
aws s3api get-public-access-block --bucket acme-artifacts

Leave all four on. For content that genuinely must be public, the correct pattern is CloudFront with an Origin Access Control, where the bucket stays private and only the distribution can read it:

{
  "Effect": "Allow",
  "Principal": { "Service": "cloudfront.amazonaws.com" },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::acme-site/*",
  "Condition": {
    "StringEquals": {
      "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1ABCDEF"
    }
  }
}

That gives you HTTPS, a custom domain, caching and cheaper egress as well as a private bucket.

ACLs are disabled, and that is good

Since 2023, new buckets default to Bucket owner enforced object ownership, which disables ACLs entirely. Every well-known S3 exposure incident traced back to an object ACL someone set without realising.

If you encounter a bucket with ACLs enabled, move it:

aws s3api put-bucket-ownership-controls \
  --bucket acme-artifacts \
  --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'

Check first that nothing relies on them. Some older tooling sets --acl bucket-owner-full-control on upload, which fails once ACLs are disabled (and becomes unnecessary, since the bucket owner then owns everything automatically).

Cross-account access

Two halves, and both are required:

The bucket policy grants the other account:

{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::444455556666:role/DataReader" },
  "Action": ["s3:GetObject", "s3:ListBucket"],
  "Resource": [
    "arn:aws:s3:::acme-artifacts",
    "arn:aws:s3:::acme-artifacts/*"
  ]
}

The other account's IAM policy must also allow it. A grant from the bucket alone is not enough. The principal's own account must permit the action too.

Two further details. If the bucket uses SSE-KMS, the KMS key policy must also grant the external principal kms:Decrypt, which is the most common reason cross-account reads fail after the S3 side looks correct. And RestrictPublicBuckets can interfere with cross-account patterns, so check BPA if a correct-looking policy is refused.

Enforcing conditions on everyone

A bucket policy applies to all principals, which makes it the right place for organisation-wide rules IAM cannot guarantee:

{
  "Sid": "DenyUnencryptedTransport",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:*",
  "Resource": [
    "arn:aws:s3:::acme-artifacts",
    "arn:aws:s3:::acme-artifacts/*"
  ],
  "Condition": { "Bool": { "aws:SecureTransport": "false" } }
}

That denies any request not over HTTPS, for everyone, regardless of their IAM permissions. Similar conditions can require a specific KMS key, restrict access to a VPC endpoint, or require MFA.

Explicit Deny beats every allow, which is exactly what makes this pattern reliable, and also why a stray Deny in a bucket policy can lock out principals you did not intend, including yourself.

Presigned URLs

The right way to grant temporary access to one object without credentials:

aws s3 presign s3://acme-artifacts/reports/q3.pdf --expires-in 3600

The URL carries the signature of whoever generated it, so it can only grant permissions that principal already has. A presigned URL from a role without GetObject on that key does not work. The maximum lifetime depends on the signing credentials: a URL signed with temporary role credentials expires when those credentials do, regardless of --expires-in, which surprises people using presigned URLs from Lambda.

Debugging AccessDenied

# 1. Who am I?
aws sts get-caller-identity

# 2. Would it be allowed, and what decides?
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::111122223333:role/AppRole \
  --action-names s3:GetObject \
  --resource-arns arn:aws:s3:::acme-artifacts/path/file.txt

# 3. What does the bucket say?
aws s3api get-bucket-policy --bucket acme-artifacts --output text | jq .
aws s3api get-public-access-block --bucket acme-artifacts

Then work through the usual causes in order:

SymptomLikely cause
Can list, cannot downloadGetObject granted on the bucket ARN instead of bucket/*
Can download, cannot listListBucket granted on bucket/* instead of the bucket ARN
403 on a key you believe existsMissing ListBucket, so a 404 is masked as 403 (check the key spelling)
Public policy has no effectBlock Public Access is on, and overrides it
Cross-account read failsThe KMS key policy does not grant the external principal
Everything denied unexpectedlyAn explicit Deny with a condition you are not meeting

IAM Access Analyzer for S3 reports which buckets are accessible externally, which is worth running whether or not anything is currently broken.

Quick reference

TaskCommand
Read the bucket policyaws s3api get-bucket-policy --bucket <b> --output text | jq .
Set a bucket policyaws s3api put-bucket-policy --bucket <b> --policy file://policy.json
Check public access blockaws s3api get-public-access-block --bucket <b>
Enforce bucket ownershipaws s3api put-bucket-ownership-controls --bucket <b> --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
Test a permissionaws iam simulate-principal-policy --policy-source-arn <arn> --action-names s3:GetObject --resource-arns <arn>
Presigned URLaws s3 presign s3://<b>/<key> --expires-in 3600
Who am Iaws sts get-caller-identity

Key takeaways

  • IAM alone is enough within an account. Bucket policies are for cross-account access and for conditions on everyone.
  • ListBucket uses the bucket ARN; GetObject uses bucket/*. Getting this wrong is the most common S3 policy bug.
  • Missing ListBucket turns a 404 into a 403, so a key typo looks like a permissions problem.
  • Block Public Access overrides a public bucket policy. Use CloudFront with an Origin Access Control instead of turning it off.
  • ACLs are disabled by default now: leave them that way.
  • Cross-account needs both sides, plus the KMS key policy if the bucket is SSE-KMS encrypted.
  • A presigned URL cannot exceed the signer's own permissions or credential lifetime.

Check your understanding

Exercise

Listing works, downloading does not

A role can list objects in a bucket but every GetObject returns AccessDenied. The policy allows both actions on "arn:aws:s3:::acme-artifacts". What is wrong?

Exercise

A public policy that does nothing

A bucket policy grants s3:GetObject to Principal "*", but anonymous requests still return 403. Why?


Frequently Asked Questions

What is the difference between an IAM policy and a bucket policy for S3?

An IAM policy attaches to a principal and answers what that user or role may do. A bucket policy attaches to the bucket and answers who may access it. Within a single account, an IAM policy alone is sufficient and no bucket policy is needed. Bucket policies matter for cross-account access, for granting anonymous access in the rare cases that is appropriate, and for enforcing conditions on every principal regardless of their IAM. Such as denying any request that does not arrive over HTTPS.

Why do I get AccessDenied when the object exists?

Check the resource ARNs first. s3:GetObject requires an object ARN ending in /* while s3:ListBucket requires the bare bucket ARN, and mixing them up produces exactly this. There is also a subtler cause: without s3:ListBucket, S3 returns 403 rather than 404 for a key that does not exist, because it will not reveal whether an object is present to a principal that cannot list. So an apparent permissions failure is sometimes a misspelled key. aws iam simulate-principal-policy settles it directly.

Why doesn't my public bucket policy work?

Block Public Access is enabled, and it overrides any policy that would grant public access. All four BPA settings are on by default for new buckets at both account and bucket level. Rather than disabling it, serve public content through CloudFront with an Origin Access Control: the bucket stays private, only the distribution can read it, and you gain HTTPS, a custom domain, caching and cheaper egress. Turning BPA off should be a deliberate and rare decision.

How do I give another AWS account access to my bucket?

Both sides must permit it. Add a bucket policy naming the external principal, ideally a specific role ARN rather than the whole account, and allow the actions on both the bucket ARN and the object ARN. The other account must also grant its principal those actions in its own IAM policy. If the bucket is encrypted with SSE-KMS, the KMS key policy must additionally allow the external principal kms:Decrypt, which is the most frequent reason a correct-looking S3 configuration still fails.

Should I use S3 ACLs?

No. ACLs are legacy and are disabled by default on new buckets under Bucket owner enforced ownership, which is the right setting. Object-level ACLs were behind most well-known S3 data exposure incidents, because a single object could be made public independently of the bucket's configuration. Use IAM policies and bucket policies instead. If you inherit a bucket with ACLs enabled, disable them after confirming nothing depends on them. Some older upload tooling sets bucket-owner-full-control, which becomes both unnecessary and invalid.

How long can a presigned URL last?

--expires-in accepts up to seven days, but the effective lifetime is capped by the credentials that signed it. A URL signed with temporary role credentials, which is the case in Lambda, ECS, EC2 with an instance profile, and anywhere else using a role. Expires when those credentials do, typically within an hour, regardless of what you requested. For genuinely long-lived links, sign with credentials that live long enough, or generate the URL on demand rather than in advance.

Continue Learning

Explore Related Topics

When it breaks

Related Resources