Customer exports found in a public S3 bucket: what shared responsibility really means
A researcher reported that CSV exports with customer emails were downloadable from an S3 bucket. The cause was a bucket policy granting public read, added years earlier for a static site. How we contained it, proved who accessed what, and locked down every bucket.
The report came to our security contact address on a Tuesday evening, from someone who described themselves as a security researcher. They had found a URL on a public code-sharing site pointing to a file in one of our S3 buckets, opened it, and found a CSV with names and email addresses of customers. They included the object key and asked us to fix it.
https://reports-export-prod.s3.eu-west-1.amazonaws.com/exports/2026-03/customers-weekly.csv
We opened the link from a network outside the company, without AWS credentials. It downloaded.
Containing first
Before understanding anything, we stopped the exposure. The fastest safe action was to enable S3 Block Public Access on the bucket, which overrides any public grant in bucket policies and ACLs:
aws s3api put-public-access-block \
--bucket reports-export-prod \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Requesting the URL again returned 403 Forbidden within a minute. We thanked the researcher,
confirmed the fix and opened an incident.
Finding the grant
The bucket policy contained this statement:
{
"Sid": "PublicReadForStaticAssets",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::reports-export-prod/*"
}
Git history of our infrastructure repository showed it had been added three years earlier, when this bucket briefly hosted static assets for an internal dashboard. The dashboard moved, the statement stayed. Eighteen months later, a new reporting job was pointed at the same bucket because "the bucket already exists and the job has permission to write there".
What we ruled out
- Leaked credentials. Our own test download worked from an outside network with no AWS credentials at all, so no stolen key or role was needed to read the file.
- Object ACLs. The bucket had
BucketOwnerEnforcedobject ownership, so ACLs were disabled. The policy alone made objects public. - Account-level Block Public Access. It had never been enabled on this account, which predated the default for new buckets.
Root cause
A bucket policy granted s3:GetObject to "Principal": "*", which means anyone on the internet. The
bucket was later reused for sensitive data without anyone re-reading its policy, and no
account-level guard existed to stop public access.
The shared responsibility model in practice
Cloud providers describe security as shared: the provider secures the infrastructure "of" the cloud, and the customer secures what they put "in" it. For S3 that means:
| AWS is responsible for | We are responsible for |
|---|---|
| Physical security of data centres | Who can access each bucket and object |
| Durability and availability of storage | Bucket policies, ACLs, Block Public Access |
| Encryption features and key infrastructure | Choosing to use them and managing key policies |
| Logging features | Turning on logging and reading it |
Nothing in AWS failed here. The service did exactly what our policy told it to do. That is the uncomfortable lesson of most cloud data exposures.
Why S3 allowed it
S3 evaluates access by combining identity policies, bucket policies, ACLs and Block Public Access.
A statement with Principal: "*" and no condition grants access to unauthenticated requests. Block
Public Access is a separate, higher-priority guard; when enabled at account or bucket level, it makes
S3 ignore or reject such grants. Since April 2023, new buckets have Block Public Access enabled by
default, but buckets created before that, or created with the setting turned off, keep whatever they
had.
Environment
| Component | Value |
|---|---|
| Cloud | AWS, single production account, eu-west-1 |
| Bucket created | 2022, before default Block Public Access for new buckets |
| Object ownership | BucketOwnerEnforced (ACLs disabled) |
| Infrastructure as code | Terraform 1.9, AWS provider 5.x |
| Logging before incident | CloudTrail management events only; no S3 data events, no server access logs |
Scoping the exposure
The hardest question in any data exposure is "who downloaded what?". With only management events in CloudTrail, we had no record of object reads before the incident. We did three things.
First, we enabled CloudTrail data events for the bucket so every future read would be logged:
aws cloudtrail put-event-selectors --trail-name org-trail \
--advanced-event-selectors '[{
"Name": "S3 data events for export buckets",
"FieldSelectors": [
{"Field": "eventCategory", "Equals": ["Data"]},
{"Field": "resources.type", "Equals": ["AWS::S3::Object"]},
{"Field": "resources.ARN", "StartsWith": ["arn:aws:s3:::reports-export-prod/"]}
]}]'
Second, we found that the CDN in front of an old dashboard domain had kept access logs that included some requests forwarded to this bucket, which gave partial history for one path.
Third, we listed every object that had existed in the bucket during the exposure window and classified the data in each. We assumed, for notification purposes, that any object could have been read. That assumption, documented with the reason, is what our legal team used for the regulator notification.
Removing the public grant properly
Block Public Access stopped the exposure, but the policy statement was still in code and would come back on the next apply if someone disabled the guard. We removed it in Terraform (the static asset use case it was written for had been retired years ago) and added a statement that denies non-TLS access:
data "aws_iam_policy_document" "reports_export" {
- statement {
- sid = "PublicReadForStaticAssets"
- effect = "Allow"
- principals {
- type = "*"
- identifiers = ["*"]
- }
- actions = ["s3:GetObject"]
- resources = ["${aws_s3_bucket.reports_export.arn}/*"]
- }
+ statement {
+ sid = "DenyInsecureTransport"
+ effect = "Deny"
+ principals {
+ type = "*"
+ identifiers = ["*"]
+ }
+ actions = ["s3:*"]
+ resources = [aws_s3_bucket.reports_export.arn, "${aws_s3_bucket.reports_export.arn}/*"]
+ condition {
+ test = "Bool"
+ variable = "aws:SecureTransport"
+ values = ["false"]
+ }
+ }
}
Turning on the guard for the whole account
resource "aws_s3_account_public_access_block" "this" {
block_public_acls = true
ignore_public_acls = true
block_public_policy = true
restrict_public_buckets = true
}
Before applying it, we ran IAM Access Analyzer to list every bucket shared publicly or with external accounts, and confirmed none of them were meant to be public. One legitimate public bucket, for a download page, was moved behind CloudFront with origin access control, so the bucket itself could stay private.
Changing how exports are shared
Customers received the weekly export by link. The new job writes to a private bucket and emails a pre-signed URL that expires after 24 hours. The object key includes a random component, and files are deleted by a lifecycle rule after 30 days.
Alternatives and trade-offs
- Bucket-level Block Public Access only. Faster, but every new bucket depends on someone remembering. The account-level setting removes the decision.
- An organisation-wide service control policy that denies
s3:PutBucketPublicAccessBlockchanges in member accounts. Stronger still; we added it after the account setting was in place.
Confirming in production
curl -s -o /dev/null -w "%{http_code}\n" \
https://reports-export-prod.s3.eu-west-1.amazonaws.com/exports/2026-03/customers-weekly.csv
# 403
aws s3control get-public-access-block --account-id 111122223333
# all four settings: true
IAM Access Analyzer showed zero active findings for public S3 access. New exports arrived through pre-signed links, and CloudTrail data events recorded each download with the requesting principal.
Regression guard
- AWS Config rules
s3-bucket-public-read-prohibitedands3-account-level-public-access-blocksalert if anything changes. - A Terraform policy check in CI fails any plan containing
"Principal": "*"with anAlloweffect on S3 unless the bucket is on an explicit exception list. - Data classification is now a required tag on buckets, and jobs that write personal data must target a
bucket tagged
confidential.
Security implications
This was a confidentiality breach of personal data and was handled as one: incident report, notification to the regulator within the legal deadline, and a notice to affected customers. The fix reduces exposure across the account, not only this bucket. Pre-signed links are bearer tokens, so they are short-lived and never logged in full.
Prevention checklist
- Enable S3 Block Public Access at the account level, and at the organisation level where possible.
- Never reuse a bucket for a different data class without re-reviewing its policy.
- Turn on S3 data events or server access logging for buckets with sensitive data before you need them.
- Use Access Analyzer or Config to detect public and cross-account access continuously.
- Share files with pre-signed URLs or a CDN with origin access control, not public buckets.
References
- AWS shared responsibility model: https://aws.amazon.com/compliance/shared-responsibility-model/
- Blocking public access to your Amazon S3 storage: https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html
- Logging data events with CloudTrail: https://docs.aws.amazon.com/awscloudtrail/latest/userguide/logging-data-events-with-cloudtrail.html
- IAM Access Analyzer: https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html
- Sharing objects with presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
Add a label
Embed this post
<iframe src="https://www.hibug.me/public-s3-bucket-shared-responsibility" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I lead security operations for engineering organisations that are too big to ignore security and too small for a large security team. That means a bit of everything: vulnerability management, logging and detection, cloud configuration reviews, phishing response and incident handling. The posts here are practical: what to set up first, what to measure, which alerts are worth waking someone for, and what to do in the first hour of an incident. Where I describe incidents, details are changed, but the mistakes and the fixes are real patterns I have seen more than once.
Related Stories
Route 53 failover not triggering while the primary region was down
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.