The s3 bucket policy vs iam policy confusion has cost me hours of debugging and once nearly exposed a client's data. Two completely different policy types can grant access to the same S3 bucket, so people set both, and then either access silently fails or the bucket is wide open and nobody notices. The fix starts with knowing which one controls what: an IAM policy is attached to a principal (a user or role) and says what that identity can do; a bucket policy is attached to the bucket and says who can touch it. For same-account access you usually need only one of them.
What is the actual difference between a bucket policy and an IAM policy?
Both are JSON documents with the same Effect/Action/Resource shape, which is exactly why they get muddled. The difference is where they attach and whose question they answer.
- IAM policy (identity-based): attached to a user, group, or role. Answers "what is THIS principal allowed to do?" It has no Principal element because the principal is whoever the policy is attached to.
- Bucket policy (resource-based): attached to the bucket itself. Answers "who is allowed to touch THIS bucket?" It must include a Principal element, because the bucket needs to know which identity it is granting to.
- Same account: an Allow in either place is enough. Most of the time you grant access with the IAM policy and leave the bucket policy empty.
- Cross-account or anonymous public access: you need the bucket policy. An IAM policy in account A can never, by itself, reach a bucket in account B unless that bucket's policy also allows it.
My rule of thumb after years of this: if the access is for a role inside the same account, write an IAM policy. The moment another account or the public internet is involved, the bucket policy is mandatory. If you want a deeper treatment of scoping identities tightly, I wrote a whole piece on least-privilege IAM roles.
How does AWS decide whether to allow a request?
This is the part that trips up everyone, and it is the reason "I added an Allow but it still 403s." AWS evaluates every applicable policy together, not one at a time. The logic, simplified for a single account:
- An explicit Deny anywhere always wins. It does not matter how many Allows you have; one Deny ends the conversation.
- If there is no explicit Deny, the request needs at least one Allow from an applicable policy (IAM or bucket).
- If nothing explicitly allows it, the default is an implicit deny. No Allow means no access.
So when same-account access fails, it is almost never "I need to add it in both places." It is either a typo in the ARN, a missing Allow, or an explicit Deny you forgot about (an SCP, a permissions boundary, or a Deny statement in the bucket policy). Adding the same Allow twice does nothing.
An explicit Deny always wins, and an implicit deny is the default. You are never granting access by adding Allows in two places; you are only ever one stray Deny away from locking yourself out.
Show me an IAM policy that lets a role read one bucket
Here is a least-privilege IAM policy attached to an application role. It grants read-only access to a single bucket and nothing else. Note the two resources: the bucket ARN itself is needed for ListBucket, and the /* ARN is needed for GetObject on the objects inside it. Forgetting the bucket-level ARN is the classic reason a ListObjects call 403s while GetObject works.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListTheBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::ryn-app-assets"
},
{
"Sid": "ReadObjects",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::ryn-app-assets/*"
}
]
}Attach this to the role, leave the bucket policy empty, and a same-account EC2 instance or Lambda using that role can read the bucket. No bucket policy required.
What does a cross-account bucket policy look like?
Now suppose a role in another account (111122223333) needs to read your bucket. The IAM policy above lives in their account and grants the role permission to try. But the bucket lives in your account, so your bucket policy has to explicitly allow their principal. Both sides are required for cross-account access: the IAM side says "this role may attempt it" and the resource side says "this bucket accepts that role."
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPartnerAccountRead",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:role/partner-reader"
},
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::ryn-app-assets",
"arn:aws:s3:::ryn-app-assets/*"
]
}
]
}Apply it with the CLI. Always use AWS CLI v2 and confirm the bucket name twice, because put-bucket-policy replaces the entire existing policy rather than merging.
aws s3api put-bucket-policy \
--bucket ryn-app-assets \
--policy file://bucket-policy-cross-account-read.json
# verify what is actually attached right now
aws s3api get-bucket-policy \
--bucket ryn-app-assets \
--output text --query PolicyWhy does my public-read bucket policy still return 403?
This is the gotcha that catches people setting up a public assets bucket. The cause is Block Public Access, which is on by default on every new bucket and sits above all your policies. With the default settings, the put-bucket-policy call that adds Principal "*" is itself rejected with AccessDenied by the BlockPublicPolicy switch, so the policy never lands. Where you see the 403-on-every-object symptom is when the policy got attached before Block Public Access was turned on, or when only the access-restricting switches are active: the statement is sitting there, looks correct, and BPA still blocks the public reads it would otherwise grant.
A public-read object policy looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::ryn-public-assets/*"
}
]
}For this to take effect you have to turn off the relevant Block Public Access settings on that specific bucket, which is a deliberate, auditable action. Per the AWS docs on Block Public Access (https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html), the four switches are layered, and a bucket-level setting will override anything more permissive. In 2026 you should rarely turn this off at all. The better pattern for a public website or assets is to keep the bucket fully private and put CloudFront in front of it with Origin Access Control, which I walk through in the S3 static site with CloudFront guide. You get public delivery without ever exposing the bucket.
Common patterns to keep straight
- Same-account app reads its own bucket: IAM policy on the role only. No bucket policy.
- Cross-account read: IAM policy in the consuming account plus a bucket policy naming that account's role. Both required.
- Public website assets: keep Block Public Access on, bucket private, serve through CloudFront with Origin Access Control. Avoid Principal "*" entirely.
- Lock a bucket to TLS only or to a VPC endpoint: add an explicit Deny in the bucket policy with a condition. Because Deny always wins, this enforces the rule even if someone later adds a broad Allow.
The whole thing collapses into one habit: decide whose question you are answering before you write a line of JSON. If you are describing what an identity can do, that is an IAM policy. If you are describing who may touch a specific bucket, especially from outside your account, that is a bucket policy. Let an explicit Deny be your backstop, treat Block Public Access as a feature rather than an obstacle, and run get-bucket-policy before every put so you never overwrite a statement you needed. Get those straight and the silent 403s and the accidentally public buckets both go away.

