S3 bucket policies are surprisingly more about controlling who can do what to which objects than they are about the bucket itself.
Let’s see how we can control access to a specific set of files in an S3 bucket. Imagine we have a bucket named my-company-assets and we want to allow a specific IAM user, arn:aws:iam::123456789012:user/data-analyst, to only read objects within the reports/2023/ prefix.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSpecificUserReadReports",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:user/data-analyst"
},
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-company-assets/reports/2023/*",
"arn:aws:s3:::my-company-assets"
]
}
]
}
This policy grants the data-analyst user the ability to perform s3:GetObject (read object data) and s3:ListBucket (list objects in the bucket) actions. Notice the Resource section: arn:aws:s3:::my-company-assets/reports/2023/* targets the specific objects, while arn:aws:s3:::my-company-assets is necessary for s3:ListBucket to work on the bucket root.
Now, what if we want to make a bucket publicly readable for website hosting, but only allow GET requests? This is a classic pattern for static websites.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-company-website/*"
}
]
}
Here, Principal: "*" means any anonymous user. The Action: "s3:GetObject" is the key – it explicitly allows only reading. Trying to perform a PUT or DELETE will fail, even though the bucket is "publicly readable." The Resource pattern arn:aws:s3:::my-company-website/* ensures this applies to all objects within the my-company-website bucket.
Consider a scenario where you need to grant cross-account access. An external auditor needs read-only access to a specific bucket, my-company-audits, which is in your AWS account (111122223333), but the auditor is in account 999988887777.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCrossAccountAuditorRead",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::999988887777:root"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-company-audits/*"
}
]
}
By specifying arn:aws:iam::999988887777:root as the principal, you allow any IAM user or role within the 999988887777 account to perform s3:GetObject on your my-company-audits bucket. To restrict this further, the auditor’s account would need to create an IAM policy on their end that only allows them to assume a role that then grants them access to your bucket.
A common mistake is over-granting s3:ListBucket. When you apply a policy that grants s3:GetObject to objects within a prefix, like reports/2023/*, you also need to grant s3:ListBucket on the bucket itself (arn:aws:s3:::my-company-assets) if you want the user to be able to list the contents of that prefix. Without the ListBucket permission on the bucket ARN, the user can’t even see what files are available under reports/2023/ using aws s3 ls s3://my-company-assets/reports/2023/.
When denying access, Deny statements always take precedence over Allow statements. This is incredibly powerful for creating exceptions. Suppose you have a broad Allow statement for your development team to access all objects in my-company-dev-bucket, but you want to prevent them from ever deleting the config/ directory.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyDeleteInConfig",
"Effect": "Deny",
"Principal": {
"AWS": "arn:aws:iam::123456789012:user/dev-team-member"
},
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::my-company-dev-bucket/config/*"
}
]
}
This Deny policy, when attached to the my-company-dev-bucket bucket policy, will explicitly block any member of the dev-team-member principal from deleting objects within the config/ prefix, regardless of any other Allow policies they might have.
The s3:ListBucket action applies to the bucket ARN, not the object ARN. This is a crucial distinction. If you try to grant s3:ListBucket on arn:aws:s3:::my-company-assets/*, it will not work. It must be on arn:aws:s3:::my-company-assets to allow listing the bucket’s contents.
You can also restrict access based on IP address. This is useful for allowing access only from within your corporate network.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowFromCorporateIP",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-company-internal-data/*",
"Condition": {
"IpAddress": {
"aws:SourceIp": "203.0.113.0/24"
}
}
}
]
}
This policy allows anonymous users to GetObject from my-company-internal-data, but only if their request originates from the IP address range 203.0.113.0/24. Any request from outside this range will be denied.
You can combine multiple Statement blocks within a single policy document to manage different access patterns for the same bucket. This allows for granular control over who can do what, where, and under what conditions.
The next hurdle is understanding how S3’s default encryption interacts with bucket policies.