Close a Public Storage Bucket and Determine What Was Accessed
For developers handling an incident where object storage was accidentally publicly readable. This runbook shows how to confirm exposure, remove public access without breaking legitimate traffic, and reconstruct what was likely accessed from logs, CDN records, and object metadata.
TL;DR — If a bucket is publicly readable, first confirm whether access is direct bucket access, website hosting, or a CDN origin path, then remove the public policy/ACL or block public access at the bucket/account level. Immediately preserve access logs and CDN logs before changing retention, then query for anonymous GET/HEAD requests to estimate what objects were fetched and from where. Reading time: ~6 min
The scenario
It is 3:40 PM on a Tuesday and someone pastes a direct object URL from your storage bucket into Slack. It loads in an incognito window with no signed URL, no auth, and no VPN. The bucket backs production assets, but you are not yet sure whether the exposure is only via a CDN, only via static website hosting, or direct anonymous reads on the bucket itself. You need to close it now without taking down the app, then work out whether anyone actually fetched sensitive objects.
Symptoms
- Opening a known object URL in a private browser session returns
200 OKwithout authentication. curlagainst a bucket/object URL succeeds anonymously:
curl -I https://bucket.example-storage.com/path/to/file.csv
HTTP/1.1 200 OK
Content-Length: 18422
Content-Type: text/csv
Last-Modified: Tue, 28 Oct 2026 14:11:03 GMT
ETag: "7d3a..."
Accept-Ranges: bytes
- Listing may or may not be public. Typical anonymous list failure while object reads still work:
curl -i https://bucket.example-storage.com/
HTTP/1.1 403 Forbidden
Content-Type: application/xml
<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>
- Static website endpoint is public even if API endpoint is not:
curl -I http://bucket-website.example-storage.com/private/report.pdf
HTTP/1.1 200 OK
Content-Type: application/pdf
- CDN masks the origin exposure; direct bucket URL and CDN URL both work:
curl -I https://cdn.example.com/private/report.pdf
curl -I https://bucket.example-storage.com/private/report.pdf
- Access logs show anonymous principals, missing auth context, or cache hits from many IPs.
- Security scanner or CSPM flags:
bucket is publicly readable,public ACL detected,bucket policy allows Principal *, orwebsite hosting exposes objects.
Likely causes
| Cause | How common | Quick check |
|---|---|---|
Bucket policy allows anonymous read (Principal: *, GetObject) | Very common | storage-cli bucket-policy get BUCKET |
| Object or bucket ACL grants public read | Common | storage-cli object-acl get BUCKET path/to/object |
| Account/bucket-level public access block is disabled | Common | storage-cli public-access-block get BUCKET |
| Static website hosting endpoint is intentionally public | Occasional | curl -I http://WEBSITE-ENDPOINT/KNOWN-OBJECT |
| CDN/origin setup allows direct bucket access in parallel with CDN | Occasional | curl -I https://BUCKET-ENDPOINT/KNOWN-OBJECT |
| Presigned URLs were mistaken for public access | Less common | curl -I "FULL_URL" |
Step-by-step diagnosis
- Confirm whether the object is truly anonymous.
curl -I https://BUCKET-ENDPOINT/path/to/object
This is your problem if you get 200 OK without any auth headers or signed query params. If the URL contains X-Amz-, Signature, Expires, or similar signed parameters and only works until expiry, jump to ### Presigned URLs were mistaken for public access.
- Check whether exposure is direct bucket access, website hosting, or only through CDN.
curl -I https://BUCKET-ENDPOINT/path/to/object && curl -I http://WEBSITE-ENDPOINT/path/to/object && curl -I https://CDN-DOMAIN/path/to/object
- Direct bucket
200: bucket policy/ACL/public-access-block issue. Continue to step 3. - Website endpoint
200but API endpoint403: jump to### Static website hosting endpoint is intentionally public. - CDN
200but bucket endpoint403: bucket may already be private; audit CDN config separately, not a bucket-public incident.
- Inspect bucket policy for anonymous read.
storage-cli bucket-policy get BUCKET
This is your problem if you see a statement equivalent to:
{"Effect":"Allow","Principal":"*","Action":["GetObject"],"Resource":"arn:...:bucket/BUCKET/*"}
Jump to ### Bucket policy allows anonymous read.
- Inspect object and bucket ACLs.
storage-cli object-acl get BUCKET path/to/object
This is your problem if you see grants equivalent to AllUsers: READ or AuthenticatedUsers: READ. Also check bucket ACL if your provider still supports ACLs:
storage-cli bucket-acl get BUCKET
Jump to ### Object or bucket ACL grants public read.
- Check public access block settings.
storage-cli public-access-block get BUCKET
This is your problem if settings that block public ACLs/policies are false or absent. Jump to ### Account/bucket-level public access block is disabled.
- Preserve evidence before changing retention or rotating logs.
⚠️ Do this before aggressive cleanup. Log retention changes, CDN cache invalidations, or bucket moves can erase the easiest evidence of what was accessed.
storage-cli logging status BUCKET
cdn-cli logs export --since "2026-10-28T00:00:00Z" --until "2026-10-28T23:59:59Z" --output ./cdn-logs/
storage-cli cp s3://LOG-BUCKET/path/to/access-logs/ ./access-logs/ --recursive
If bucket access logging was off, you will need CDN logs, load balancer logs, WAF logs, application references to object URLs, and object metadata such as Last-Modified and ETag to narrow likely access.
- Query logs for anonymous object reads.
grep -E 'GET|HEAD' ./access-logs/* | grep -E 'Anonymous|-|200|206|304'
This is your problem scope if you see repeated requests for sensitive prefixes, many source IPs, unusual user agents, or byte-range requests (206 Partial Content) indicating real downloads. If logs are present, continue to fixes and then perform the audit steps in the relevant fix section.
Fixes
Bucket policy allows anonymous read
Remove the anonymous allow statement and replace it with a least-privilege policy for only the principals that should read.
storage-cli bucket-policy get BUCKET > /tmp/bucket-policy.json
$EDITOR /tmp/bucket-policy.json
storage-cli bucket-policy set BUCKET --file /tmp/bucket-policy.json
Delete statements with anonymous principals and public read actions, for example:
{
"Effect": "Allow",
"Principal": "*",
"Action": ["GetObject"],
"Resource": ["arn:...:bucket/BUCKET/*"]
}
If you need an immediate hard stop, apply a deny on anonymous reads while you sort out legitimate access:
{
"Effect": "Deny",
"Principal": "*",
"Action": ["GetObject"],
"Resource": ["arn:...:bucket/BUCKET/*"],
"Condition": {"StringEquals": {"authType": "Anonymous"}}
}
Trade-off: an emergency deny can also break website hosting, health checks, and any service that was incorrectly relying on anonymous origin access.
Audit what was accessed by querying preserved logs for object key, source IP, status, bytes, and user agent:
awk '/GET|HEAD/ {print $1,$4,$5,$7,$9,$10,$12}' ./access-logs/* | sort | uniq -c | sort -nr | head -50
Verify it worked:
curl -I https://BUCKET-ENDPOINT/path/to/object
Expect 403 Forbidden or equivalent access denied.
Object or bucket ACL grants public read
Remove public ACL grants from the affected objects and bucket. If many objects are affected, use a recursive ACL reset.
storage-cli object-acl set BUCKET path/to/object --private
storage-cli bucket-acl set BUCKET --private
storage-cli objects set-acl BUCKET --prefix sensitive/ --acl private --recursive
If your provider supports disabling ACLs entirely in favor of policy-only access, do that after cleanup to prevent recurrence.
Audit impact by comparing the list of objects with public ACLs against access logs:
storage-cli objects list-acl BUCKET --recursive | grep 'AllUsers:READ'
grep 'sensitive/' ./access-logs/* | grep -E 'GET|HEAD'
Edge case: cached CDN copies may still serve after origin ACL removal until TTL expiry or invalidation.
Verify it worked:
storage-cli object-acl get BUCKET path/to/object
curl -I https://BUCKET-ENDPOINT/path/to/object
Expect no AllUsers grant and an access-denied response.
Account/bucket-level public access block is disabled
Enable public access blocking at both bucket and account/project scope where available. This prevents future public policies/ACLs from taking effect.
storage-cli public-access-block set BUCKET \
--block-public-acls true \
--ignore-public-acls true \
--block-public-policy true \
--restrict-public-buckets true
If your provider has an account/project-level equivalent, set it there too in the dashboard under the storage security settings for the account/project.
Trade-off: this can break intentionally public website buckets. If you truly need public content, put it behind a CDN and keep the bucket private to the CDN origin identity.
Audit by checking whether previous public policy/ACLs are now ignored and whether anonymous requests fail:
storage-cli public-access-block get BUCKET
curl -I https://BUCKET-ENDPOINT/path/to/object
Verify it worked: the block settings read back as enabled and anonymous GET returns 403.
Static website hosting endpoint is intentionally public
If the bucket is being used as a website origin, the website endpoint is usually public by design. The fix is architectural: disable website hosting for sensitive content, move public assets to a dedicated public bucket, or front the bucket with a CDN and private origin access.
Disable website hosting if not needed:
storage-cli website disable BUCKET
Or split content:
storage-cli sync ./public-assets BUCKET-public/
storage-cli sync ./private-assets BUCKET-private/
Then update app/config so private files are served via signed URLs or app-authenticated download endpoints, not website URLs.
Audit by searching logs specifically for website endpoint hostnames and sensitive prefixes:
grep 'bucket-website' ./cdn-logs/* ./access-logs/* | grep -E 'GET|HEAD'
Verify it worked:
curl -I http://WEBSITE-ENDPOINT/path/to/object
Expect 404, 403, or connection failure if website hosting is disabled.
CDN/origin setup allows direct bucket access in parallel with CDN
Lock the bucket so only the CDN origin identity or source network can fetch objects. The exact mechanism varies by provider, but the pattern is the same: deny direct anonymous reads, allow only the CDN-origin principal.
{
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "cdn-origin-identity"},
"Action": ["GetObject"],
"Resource": ["arn:...:bucket/BUCKET/*"]
},
{
"Effect": "Deny",
"Principal": "*",
"Action": ["GetObject"],
"Resource": ["arn:...:bucket/BUCKET/*"],
"Condition": {"StringEquals": {"authType": "Anonymous"}}
}
]
}
If your CDN cannot present an origin identity, restrict by source IP ranges only as a temporary measure; this is brittle because provider IP ranges change.
Audit with both URLs after the change:
curl -I https://BUCKET-ENDPOINT/path/to/object
curl -I https://CDN-DOMAIN/path/to/object
Verify it worked: bucket endpoint returns 403, CDN still returns 200.
Presigned URLs were mistaken for public access
If the object only works with signed query parameters and fails after expiry, the bucket may not be public. The incident is then about URL leakage, overlong expirations, or logs/chat history containing valid URLs.
Check by stripping query params:
curl -I "https://BUCKET-ENDPOINT/path/to/object"
curl -I "https://BUCKET-ENDPOINT/path/to/object?X-Amz-Algorithm=...&X-Amz-Signature=..."
Typical expired response:
HTTP/1.1 403 Forbidden
Content-Type: application/xml
<Error><Code>AccessDenied</Code><Message>Request has expired</Message></Error>
Fix by reducing presigned URL TTLs in code and rotating any credentials used to mint them if abuse is suspected.
Verify it worked: unsigned URL fails; newly generated signed URLs have short expiry and old leaked URLs no longer work.
Prevention
- Enable storage access logging and send it to a separate log bucket with retention and immutability where available.
storage-cli logging enable BUCKET --target-bucket LOG-BUCKET --prefix bucket-access/
storage-cli retention set LOG-BUCKET --days 90
- Block public access at account/project scope and bucket scope by default in your bootstrap/IaC.
{
"block_public_acls": true,
"ignore_public_acls": true,
"block_public_policy": true,
"restrict_public_buckets": true
}
- Add a CI policy check that fails on anonymous principals or public ACLs.
terraform show -json plan.out | jq -e '.. | objects | select(.Principal? == "*")' >/dev/null && exit 1
rg -n 'AllUsers|AuthenticatedUsers|Principal"\s*:\s*"\*"' infra/
- Separate public and private content into different buckets and naming prefixes so one bad policy does not expose everything.
View as text
[Build] ---- 1. sync public ----> [Public Bucket] [App] ---- 2. signed URL ----> [Private Bucket] [CDN] ---- 3. origin fetch ---> [Public Bucket]
- Alert on anonymous reads to sensitive prefixes or unusual spikes in
GET/206volume.
grep ' sensitive/' ./access-logs/* | awk '{print $1,$7,$9,$10}' | sort | uniq -c | sort -nr
Wire the equivalent query into your log platform and page on thresholds.
- If you use a CDN, require private origin access and continuously test that the origin URL is not directly reachable.
curl -fsSI https://BUCKET-ENDPOINT/healthcheck.txt && exit 1 || exit 0
Run that in scheduled CI; success should mean the direct origin is blocked, not reachable.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI