Both are JSON documents. Both use Effect, Action, Resource, and (optionally) Principal. Both can grant or deny S3 access. The confusion isn’t the syntax — it’s that two differently-attached policy types can both apply to the same request, and knowing which one is actually blocking access is what makes S3 permission debugging either quick or miserable.
Where each one is attached
An IAM policy is attached to an identity — a user, a group, or a role. It answers “what can this identity do,” regardless of which bucket it’s touching, as long as that bucket doesn’t say otherwise.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::my-app-uploads/*"
}]
}
Attached to a role, this says: whoever assumes this role can read/write objects in my-app-uploads.
A bucket policy is attached to the bucket itself. It answers “who can access this bucket,” and can grant or deny permissions to specified principals.
For access within the same AWS account, a bucket policy can grant a principal permissions even when that principal’s identity-based policy doesn’t explicitly grant them — subject to other applicable controls. For cross-account access, a bucket policy alone generally isn’t enough: the resource-owning account has to allow it via the bucket policy, and the requesting account has to separately authorize that principal via an identity-based policy on its own side. A bucket policy can also grant public access when deliberately configured for anonymous requests — but only if S3 Block Public Access settings and other applicable controls aren’t blocking it.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::123456789012:role/app-server" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-app-uploads/*"
}]
}
(123456789012 and my-app-uploads here are placeholder example values — not meant to be copied into a deployed policy as-is.)
Note the Principal field — a bucket policy has to specify who it applies to, since it isn’t attached to an identity the way an IAM policy is.
When to use which
- IAM policy: the default choice for defining what an application’s own user or role can do. Identity-based policies are particularly useful for managing permissions centrally across multiple resources, rather than scattered across every bucket that identity touches.
- Bucket policy: use for cross-account resource access, for public access when explicitly required, and for resource-side controls that should apply to requests against the bucket regardless of which identity is asking — restricting by source IP address, or denying requests that don’t use HTTPS (via a
Denyconditioned on insecure transport), are common examples.
For cross-account access, both sides need configuring — the bucket policy and the requesting identity’s own permissions. For public access, check the bucket’s S3 Block Public Access settings before relying on the bucket policy alone. And a bucket policy isn’t automatically a blanket restriction over every possible access path to that bucket — its actual effect depends on the specific principals, actions, resources, and conditions it’s written with.
What wins when they disagree
Access is denied by default unless an applicable policy actually permits the request — that’s an implicit deny, distinct from an explicit deny, which is a policy actively saying “no.” An explicit deny in any applicable policy overrides an allow regardless of which policy it came from, identity-based or resource-based.
Within the same AWS account, an applicable allow in either the identity-based policy or the bucket policy can be sufficient on its own — they don’t both have to agree — provided nothing else blocks it. So if an IAM policy allows s3:GetObject but the bucket policy explicitly denies it for that principal, the request is still denied: the explicit deny wins, no matter which side it came from. This is the detail that causes the most confusing debugging sessions: an IAM policy that looks correct, blocked by a bucket policy nobody checked.
Cross-account requests are stricter: they generally need authorization on both sides — the requesting account’s identity-based policy has to allow the action, and the resource-owning account’s bucket policy has to separately allow that external principal. Permissions boundaries, session policies, and AWS Organizations policies (SCPs/RCPs) can further restrict access beyond either policy type.
Practical debugging order
- Identify the request — the exact IAM identity/role, the S3 action, the bucket/object ARN, and whether it’s same-account or cross-account.
- Check identity-based permissions — does the IAM user/role have the required allow, and is there an explicit deny anywhere on that side?
- Check the bucket policy — inspect its
Principal,Action,Resource, andConditionfields for an explicit deny or a missing grant. - Check additional controls — permissions boundaries, session policies, AWS Organizations SCPs/RCPs, and any applicable VPC endpoint policy.
- Check S3-specific settings — Block Public Access (if public access is involved), object ownership/ACLs where relevant, and KMS key permissions if the object is encrypted with a customer-managed key.
- Inspect the error and CloudTrail logs — S3’s enhanced
AccessDeniedmessages, where available, often name the specific policy responsible for the denial.
Not every access failure traces back to an IAM policy or a bucket policy — the actual cause depends on which of these applies to the specific request.
See also: AWS Lambda Cold Starts: What Causes Them and How to Reduce Them for a common case where IAM role permissions (not bucket policies) are the usual access layer, and Cloud Service Status Pages: AWS, Google, Apple & Azure for ruling out an AWS-side S3 availability issue before assuming it’s a permissions bug.