S3 security: who can read a bucket
"Our S3 bucket was public" is the headline of more data breaches than any other cloud mistake. AWS has spent years making it harder: since April 2023 every new bucket blocks public access and has ACLs disabled. But the defaults can be switched off with one call, old buckets predate them, and a bucket policy is a JSON document someone edits at 18:00 on a Friday. This lesson is the layers that decide who can read a bucket, how S3 decides that a policy is "public", and how to share data without making anything public.
Need to know: access to an object is decided by IAM policies, the bucket policy, Block Public Access (BPA: four switches, on the bucket and on the account, the stricter wins), Object Ownership (with BucketOwnerEnforced, the default, ACLs are disabled) and encryption. A policy is public when it allows "Principal": "*" without a condition that pins the caller to known values. BPA's BlockPublicPolicy refuses such a policy; RestrictPublicBuckets ignores the public part of one that already exists. To share an object, use a presigned URL or a CDN, not a public bucket.
The layers
| Layer | Set on | Decides |
|---|---|---|
| IAM identity policies | users, roles | what your own principals may do in S3 |
| Bucket policy | the bucket | who else (other accounts, everyone, services) may do what, and extra Deny rules for everyone |
| Block Public Access | the account (s3control) and each bucket | a veto: public policies and public ACLs are refused or ignored |
| Object Ownership | the bucket | who owns uploaded objects; BucketOwnerEnforced = the bucket owner, ACLs off |
| ACLs (legacy) | bucket and objects | grants by canonical user or group; off on modern buckets |
| Encryption | the bucket default, each object | which key; with SSE-KMS the KMS key policy is another layer |
$ aws s3api get-public-access-block --bucket try-press-111122223333
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}
$ aws s3control get-public-access-block --account-id 111122223333
aws: [ERROR]: An error occurred (NoSuchPublicAccessBlockConfiguration) when calling the GetPublicAccessBlock operation: The public access block configuration was not found
Additional error details:
AccountId: 111122223333
$ aws s3api get-bucket-ownership-controls --bucket try-press-111122223333
{
"OwnershipControls": {
"Rules": [
{
"ObjectOwnership": "BucketOwnerEnforced"
}
]
}
}
$ aws s3api get-bucket-policy --bucket try-press-111122223333
aws: [ERROR]: An error occurred (NoSuchBucketPolicy) when calling the GetBucketPolicy operation: The bucket policy does not exist
Additional error details:
BucketName: try-press-111122223333
The bucket has the 2023 defaults: all four BPA settings on, ACLs disabled, no policy. The account has no BPA configuration at all - each bucket is on its own. Turning BPA on for the account is the single most effective S3 control: it covers every bucket, including the ones created next year by someone who turned the bucket-level switch off.
The four switches
| Setting | Effect |
|---|---|
BlockPublicAcls | refuse new public ACLs (PutBucketAcl, PutObjectAcl, PutObject with a public ACL) |
IgnorePublicAcls | ignore public ACLs that already exist |
BlockPublicPolicy | refuse a PutBucketPolicy that would make the bucket public |
RestrictPublicBuckets | if the bucket policy is public anyway, only AWS services and principals of the bucket owner's account can use it: anonymous and other accounts are denied |
What "public" means to S3
S3 evaluates a bucket policy and calls it public if some Allow statement grants to everyone ("Principal": "*" or {"AWS": "*"}) and is not limited by a condition with a fixed value on keys such as aws:SourceIp (a specific range, not 0.0.0.0/0), aws:SourceVpce, aws:SourceVpc, aws:PrincipalOrgID, aws:PrincipalAccount, aws:SourceArn or aws:SourceAccount. The press team wants their logo world-readable:
$ cd ~/oncall-lab/labs/aws
$ cat try/press-public.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::try-press-111122223333/*"
}
]
}
$ aws s3api put-bucket-policy --bucket try-press-111122223333 --policy file://try/press-public.json
aws: [ERROR]: An error occurred (AccessDenied) when calling the PutBucketPolicy operation: User: arn:aws:iam::111122223333:user/learner is not authorized to perform: s3:PutBucketPolicy on resource: "arn:aws:s3:::try-press-111122223333" because public policies are prevented by the BlockPublicPolicy setting in S3 Block Public Access.
BlockPublicPolicy refused it, and the error says why. To publish it anyway, someone must first switch BPA off for the bucket - which is exactly what happens in the incidents:
$ aws s3api delete-public-access-block --bucket try-press-111122223333
$ aws s3api put-bucket-policy --bucket try-press-111122223333 --policy file://try/press-public.json
$ aws s3api get-bucket-policy-status --bucket try-press-111122223333
{
"PolicyStatus": {
"IsPublic": true
}
}
$ curl -s https://try-press-111122223333.s3.eu-central-1.amazonaws.com/press-release.txt
oncall-lab launches its on-call trainer.
get-bucket-policy-status is S3's own verdict, and the one auditors check. Now put the brakes back on without touching the policy, and watch RestrictPublicBuckets work:
$ aws s3api put-public-access-block --bucket try-press-111122223333 --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
$ curl -s https://try-press-111122223333.s3.eu-central-1.amazonaws.com/press-release.txt
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied</Message><RequestId>BBE7F370AF079655</RequestId><HostId>Lx9w+/YzPovPcHufYUokrcb76FEOh5CdlKCVZKi/COlTrlDjX1oEaoxeZcFP4cHnCLop3rJjpr4j=</HostId></Error>
$ aws s3 cp s3://try-press-111122223333/press-release.txt -
oncall-lab launches its on-call trainer.
The public policy is still there, but anonymous requests are denied again, while your own account keeps access. That is the incident move: re-enable BPA first (seconds, reversible), then fix the policy calmly.
Bucket policy patterns worth knowing
A bucket policy with a Deny applies to everyone, admins included - which makes it the place for rules that must hold whatever IAM says:
$ cat try/deny-insecure.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::try-press-111122223333",
"arn:aws:s3:::try-press-111122223333/*"
],
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
}
}
}
]
}
$ aws s3api put-bucket-policy --bucket try-press-111122223333 --policy file://try/deny-insecure.json
$ curl -s http://try-press-111122223333.s3.eu-central-1.amazonaws.com/press-release.txt
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied</Message><RequestId>9A774FA9413C8090</RequestId><HostId>z4dVWntBw/IwcmW+i2YKi3ajxlAyyFb+4wvFwEf8JVUrp080WSBc2A9POffweQnUDsRguFFDrnCx=</HostId></Error>
$ aws s3 cp s3://try-press-111122223333/press-release.txt -
oncall-lab launches its on-call trainer.
- TLS only:
Denys3:*whenaws:SecureTransportisfalse(above). Standard in every compliance baseline. - Grant another account or role:
Allowwith"Principal": {"AWS": "arn:aws:iam::444455556666:role/reader"}- the other account must also allow it in IAM. - Private network only:
Denyunlessaws:SourceVpceis your VPC endpoint (AWS II). - CloudFront: allow the service principal
cloudfront.amazonaws.comwithaws:SourceArn= your distribution (Origin Access Control) - public website, private bucket. - Your organization:
aws:PrincipalOrgID= your org ID instead of listing accounts.
ACLs: the old way, now off
ACLs (bucket and object grants to canonical user IDs or the groups AllUsers / AuthenticatedUsers) predate bucket policies. With Object Ownership set to BucketOwnerEnforced they are disabled: every object belongs to the bucket owner, and any request that tries to set an ACL other than private fails:
$ aws s3api put-object --bucket try-press-111122223333 --key public.txt --body try/press-public.json --acl public-read
aws: [ERROR]: An error occurred (AccessControlListNotSupported) when calling the PutObject operation: The bucket does not allow ACLs
$ aws s3api get-bucket-acl --bucket try-press-111122223333 --query 'Grants[].[Grantee.Type,Permission]' --output text
CanonicalUser FULL_CONTROL
Old tools and tutorials still pass --acl public-read; on a modern bucket that is an error, not a silent public object - one more reason to leave ownership enforced.
Sharing without going public
- Presigned URLs for one object, for a while (the S3 lesson).
- CloudFront with Origin Access Control in front of a private bucket for websites and downloads.
- A role another account can assume, or a bucket policy grant to that account, for partners.
- Access points for many consumers with different rules.
In an interview: "How do you make sure no S3 bucket in the account is public?" - "Turn on Block Public Access at the account level (all four settings), keep Object Ownership on BucketOwnerEnforced, and protect the setting with an SCP that denies s3:PutAccountPublicAccessBlock. Then monitor: get-bucket-policy-status / IAM Access Analyzer findings for buckets shared outside the account. Share data with presigned URLs, CloudFront or explicit cross-account grants instead."
You can now: list the layers that decide S3 access, read and set Block Public Access at the bucket and account level, explain S3's definition of a public policy, contain a public bucket in seconds with BPA, write the TLS-only and cross-account bucket policy patterns, and explain why ACLs fail on modern buckets.