In September 2017, a security researcher typed a six-letter subdomain into his browser, hit enter, and downloaded files marked Top Secret and NOFORN — intelligence material the US government cannot legally share even with allied nations. The US Army's INSCOM bucket was simply public, and the address was a guess. No password was checked, no exploit was run. That is the entire vulnerability behind most cloud storage leaks, and it is sitting in front of hundreds of thousands of organizations right now — automated indexers already catalog roughly 470,000 open buckets and 15 billion publicly accessible files. If your object storage is misconfigured, anyone with a wordlist and a free CLI can list and download every file you've ever put in it.
The short version: a publicly listable bucket is one anonymous request from total disclosure, which is why it's a CRITICAL finding by impact — not by novelty. Here's what every team should walk away with:
- One anonymous request dumps everything.
aws s3 sync --no-sign-request,gsutil ls, or a singlecurlto?restype=container&comp=listpulls every object. The access control already failed; what's left is a recursive copy. - Discovery is passive and automated. Bucket names live in a global, guessable namespace, and aggregators already index hundreds of thousands of open buckets and billions of files across S3, Azure, and GCS (GrayhatWarfare, March 2025). Your exposure gets found by automation in hours, not stumbled upon.
- Buckets are credential reservoirs. Accenture exposed an AWS KMS master key in one bucket and ~40,000 plaintext passwords in another; Booz Allen exposed live SSH and admin keys; the US Army INSCOM bucket held Top Secret intel — all public, no password. The real prize inside "just files" is the secrets that unlock everything else.
- Each cloud has one account-level control that overrides every bucket. AWS account-level Block Public Access, Azure
AllowBlobPublicAccess=false, GCS Public Access Prevention. New-resource defaults improved in 2023, but pre-cutoff buckets were never retrofitted — that long tail is exactly what gets hunted. - Signed/SAS URLs are bearer tokens, not single-use links. Keep expirations in minutes, mint them server-side, and never commit them to git.
From a misconfigured WAF to 106 million records — but most buckets need no WAF at all
In 2019, a former AWS engineer named Paige Thompson found a server-side request forgery (SSRF) flaw in a misconfigured web application firewall running on a Capital One EC2 instance. She used it to make the WAF fetch a URL of her choosing — the EC2 instance metadata endpoint at http://169.254.169.254/latest/meta-data/iam/security-credentials/. On IMDSv1, an unauthenticated GET to that path returns the role name attached to the instance, and a second GET to .../security-credentials/<role-name> returns the role's temporary IAM credentials. The WAF's role was wildly over-privileged: it carried broad S3 access across the account. With those stolen credentials, Thompson ran aws s3 sync against roughly 700 buckets and copied the contents out. The damage: about 106 million customers exposed — 100 million in the US, 6 million in Canada — including roughly 140,000 Social Security numbers and 80,000 linked bank account numbers. The regulatory tail was just as heavy: a $40.7 million restitution order at sentencing (DOJ, Western District of Washington), on top of an $80 million OCC's $80 million civil penalty and a $190 million class-action settlement.
Capital One is the dramatic case — and the wrong mental model for almost everyone reading this. It needed an SSRF and a stolen role. Most public S3 bucket leaks need neither — they are simply public. The bucket ACLs in the Capital One incident were never the point; the attacker came in through identity, not through a world-readable object store. That makes it the outlier — the IAM-pivot variant — not the representative case. The representative case is a bucket that anyone on the internet can list and dump with a single anonymous request, and it's what sits behind the dominant search intent for open S3 bucket and exposed cloud storage. This post is about object and blob storage that's wide open across all three major clouds — Amazon S3, Azure Blob Storage, and Google Cloud Storage — and it's told from both sides: how attackers find and empty these buckets, and exactly how you lock yours down.
Once you internalize that distinction, the severity question answers itself. A publicly listable bucket is one anonymous GET away from total dataset disclosure. There's no exploit chain to develop, no credential to phish, no privilege to escalate — the access control already failed, and what's left is a recursive copy. That is why a publicly readable bucket is a CRITICAL finding, every time, regardless of how routine or boring the underlying mistake is. We'll make the full severity argument shortly; for now, hold the stance: severity comes from impact-on-compromise, not from how common or low-effort the misconfiguration happens to be.
The reason "no password" is enough is that buckets are external attack surface by construction. Object storage doesn't live behind your VPC or your firewall — it lives at a public, predictable URL on a shared namespace, reachable whether or not anyone has linked to it, indexed it, or remembers creating it. Attackers don't need to be inside your network or your org chart to find these; they permute your company name against a wordlist of suffixes and probe the namespace directly. So your buckets are external attack surface whether or not you remember they exist. A bucket spun up for a one-off data export in 2019 and never deleted is just as reachable today as your production CDN origin.
Scope: public bucket and blob exposure only — and what lives next door
"Cloud exposure" is a phrase people use to mean a dozen different failures, and conflating them is how remediation advice turns to mush. This post is about object storage that you can list and download with no credentials: an Amazon S3 bucket, a Google Cloud Storage bucket, or an Azure Blob container that returns a full file listing to an anonymous request. That's it. The files you stored, readable by anyone who knows or guesses the name. The discipline matters because the fix for a public bucket — an account-level public-access block plus least-privilege IAM — is not the fix for any of the adjacent problems below, and treating them as one bucket of "cloud risk" leaves you with a checklist that secures nothing in particular.
Object storage sits in a neighborhood of failures that feel identical — unauthenticated, internet-reachable, CRITICAL by impact — but have completely different root causes and remediations. Here's the map of what lives next door, and where each one is actually handled.
Covered elsewhere — and why it's not this post
- Unauthenticated databases and caches. An open Redis, MongoDB, Elasticsearch, or Memcached port is the same severity tier as a public bucket — total, unauthenticated data disclosure with no exploit required — but it is a fundamentally different surface. A bucket is an HTTPS object store you accidentally made world-readable; a database is a network service you accidentally bound to
0.0.0.0and left without auth. You fix a public bucket with a storage-account setting; you fix an open Redis instance by binding it to localhost, settingrequirepass, and getting port6379off the public internet entirely. They don't share a control plane, so they don't share a remediation. That whole class is its own full-compromise story — an open Redis or MongoDB port is its own full-compromise story, and the open-Redis remediation path is laid out in exactly how Redis on port 6379 goes fromFLUSHALLto RCE. We won't re-teach the database surface here. - Subdomain takeover via a dangling S3 endpoint. This is the trap that catches the most people, because it also involves S3 and the word "abandoned." But it is the opposite failure. A data leak is a bucket that still exists and is readable. A takeover is when an abandoned subdomain still pointing at a deleted S3 website endpoint — the bucket is gone, the DNS
CNAMEis not — lets an attacker re-register that now-available name to serve content under your domain. One is "your data walked out the door"; the other is "someone else's content walked in under your brand." Takeover is not data leakage, and we're not going to blur the two. - Leaked
.envfiles and the keys inside them. This one we will reference, because it's the causal chain that makes public buckets so dangerous — but we won't re-teach it. The credentials attackers use to find and dump even private buckets have to come from somewhere, and overwhelmingly that somewhere is an exposed environment file. A leaked.envfile is the single fastest route to the keys that dump every bucket you own —AWS_ACCESS_KEY_ID, a Google service-account JSON, an Azure storage connection string, all sitting in plaintext at/.envor/.env.bakon a web root. Once an attacker has those, listing being disabled stops mattering; they're authenticated. We'll come back to this exact pivot in the attacker-workflow section, but the full story lives in its own post.
All four — public bucket, open database, dangling subdomain, leaked .env — share one trait: none of them needs an exploit. There's no CVE to patch, no payload to craft, no zero-day. They're just reachable, and that's the whole vulnerability. But each one is its own finding with its own fix, and for the rest of this post we are talking about exactly one of them: storage you can list and download, no password.
How object storage goes public (S3, Azure Blob, GCS misconfiguration)
Every public bucket is the same story told in three dialects. The mechanism that flips storage public differs across AWS, Azure, and Google Cloud, but the failure mode is identical: a name an attacker can guess, plus an access setting that says "anyone." If you understand the access model for one cloud, the other two are a short translation away.
The global namespace problem (why names are guessable in the first place)
On AWS, S3 is a single global namespace: a bucket name is unique across every AWS customer on earth. There is exactly one bucket named company-backups, and either you own it or someone else does. That uniqueness is precisely what makes names guessable — there's no account ID or region to brute-force around, just the name itself. Buckets resolve at three endpoint forms:
https://<bucket>.s3.amazonaws.com/
https://<bucket>.s3.<region>.amazonaws.com/
https://s3.amazonaws.com/<bucket> # path-style (legacy; still resolves for older buckets)
Google Cloud Storage works the same way — globally unique bucket names, reachable two ways:
gs://<bucket>
https://storage.googleapis.com/<bucket>
Azure splits the namespace one level deeper. The storage account name is globally unique; containers live inside an account and are unique only within it. So the guessable surface is <account>.blob.core.windows.net, and then a container name on top:
https://<account>.blob.core.windows.net/<container>/<blob>
None of these URLs requires a login to attempt. Whether the attempt succeeds is the entire question — and the answer is set by access controls that, on legacy resources, default open far more often than the improved 2023 defaults would suggest.
AWS: four layers, and the master switch that overrides all of them
S3 access is governed by four mechanisms that stack, and precedence matters more than any single setting. From most to least authoritative:
- Account-level Block Public Access (BPA) — the master switch. Overrides everything below it.
- Bucket policy — JSON attached to the bucket.
- IAM identity policy — permissions attached to a user or role.
- ACLs — the legacy per-object/per-bucket grant system.
Almost every classic S3 leak traces to one of two of these. The first is the legacy ACL footgun: a grant to the AllUsers group (anyone, no auth) or AuthenticatedUsers group. Read AuthenticatedUsers carefully — on S3 it means anyone with any AWS account, which is free to create, so it's effectively public too. The UpGuard-era incidents were all this exact thing: public-read ACLs on a bucket nobody remembered to lock down.
The second is an overly broad bucket or IAM policy — a statement with "Principal": "*" granting public read:
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::company-backups",
"arn:aws:s3:::company-backups/*"]
}
That grants anonymous listing and download of the entire bucket. Swap s3:GetObject for s3:PutObject — or, catastrophically, s3:* — and you've granted the public the ability to write and overwrite objects, which turns a data leak into a content-tampering and supply-chain vector if the bucket serves site assets.
Block Public Access is the answer to both. It exposes four independent settings — BlockPublicAcls, IgnorePublicAcls, BlockPublicPolicy, RestrictPublicBuckets — and when all four are enabled at the account level, they override any conflicting ACL or bucket policy on every current and future bucket (AWS: Blocking public access to your S3 storage). It is the single highest-leverage control on AWS, full stop.
The critical caveat: beginning in April 2023, AWS automatically enables BPA and disables ACLs (Object Ownership = BucketOwnerEnforced) for all new buckets, in every region (AWS What's New, Dec 2022). But existing buckets were not retrofitted, and the account-level switch is not turned on for you. The entire long tail of pre-2023 buckets — and any account where someone explicitly set a public policy — is exactly where exposures still live, and exactly what attackers and scanners hunt.
GCS: allUsers, and the principal everyone misreads
Google Cloud governs bucket access through IAM bindings. A bucket goes public when its IAM policy grants a role (typically roles/storage.objectViewer) to one of two special principals:
allUsers— anyone on the internet, unauthenticated. Unambiguously public.allAuthenticatedUsers— and here's the trap. This does not mean "anyone in my organization." It means any Google account anywhere on earth — every Gmail address, every Workspace user at any company. Free to obtain, so it is effectively public. Engineers grant it thinking it scopes access to their team, and it scopes access to the entire planet.
You can spot either grant in seconds:
gcloud storage buckets get-iam-policy gs://<bucket> \
--format=json | grep -E 'allUsers|allAuthenticatedUsers'
GCS's account-level kill switch is Public Access Prevention. When enforced, it hard-blocks any grant of allUsers or allAuthenticatedUsers: requests authorized as those principals fail with HTTP 401/403, and attempts to add them to an IAM policy fail with 412 Precondition Failed (Google Cloud: Public access prevention). It can be enforced per-bucket or, far better, organization-wide via the storage.publicAccessPrevention org policy constraint at the project, folder, or org level. Unlike AWS and Azure, GCS does not turn this on by default; it's opt-in. Assume nothing is protecting you until you've enforced it.
Azure: container access levels and the account-wide kill switch
Azure exposure has two moving parts that both have to line up wrong. At the container level, anonymous access has three settings:
- Private — no anonymous access (the safe state).
- Blob — anyone who knows a blob's exact URL can read that blob, but can't list the container.
- Container — anyone can list every blob in the container and read them. This is the full-enumeration setting.
But a container's access level only matters if the storage account permits anonymous access at all. The account-level AllowBlobPublicAccess property is the override: set it to false and every blob request must be authorized, regardless of any container's individual setting (Microsoft Learn: Remediating anonymous read access). That single account-level boolean is Azure's equivalent of S3 Block Public Access. As with AWS, the default improved but only going forward: storage accounts created from August 2023 onward disable anonymous access by default. Pre-cutoff accounts are untouched.
Predictable names: the accelerant that makes all of this trivial
A public access setting is only dangerous if someone finds the bucket. The shortcut is that organizations name buckets the way they name everything else — descriptively, with environment suffixes. company-prod-backups. company-terraform-state. company-db-dumps. These names do two things at once: they make brute-force trivial (a wordlist of a few dozen suffixes against your brand name covers most of them), and they advertise the contents — an attacker who finds company-db-dumps doesn't need to guess what's inside. The naming convention that helps your engineers is the same one that helps an attacker, because both reason from the same logic. This is, almost exactly, what an automated discovery pass does — and it's what FortWatch's cloud exposure scanner enumerates bucket names across S3, GCS, and Azure Blob for, deriving candidates from your domain plus environment suffixes and probing each cloud for a public listing response.
Tri-cloud parity at a glance
Same failure, three dialects. Here's the whole model in one table — endpoint form, the principal or setting that makes a bucket public, and the account-level control that overrides every bucket (full commands come in the lockdown section):
| Cloud | Endpoint pattern | The "public" mechanism | Account/org override |
|---|---|---|---|
| AWS S3 | <bucket>.s3.amazonaws.com · s3.amazonaws.com/<bucket> (global namespace) |
AllUsers/AuthenticatedUsers ACL, or bucket/IAM policy with Principal: "*" granting s3:GetObject/s3:ListBucket (read) or s3:PutObject/s3:* (write) |
Block Public Access — all four settings at account level (on by default for new buckets since Apr 2023; pre-2023 not retrofitted) |
| Google Cloud Storage | gs://<bucket> · storage.googleapis.com/<bucket> (global namespace) |
IAM binding to allUsers (anyone, unauthenticated) or allAuthenticatedUsers (any Google account worldwide — not your org) |
Public Access Prevention (storage.publicAccessPrevention org policy) — opt-in, not on by default |
| Azure Blob | <account>.blob.core.windows.net/<container> (account name global, container per-account) |
Container access level set to Container (list + read all) or Blob (read known blob) while account allows it |
AllowBlobPublicAccess = false on the storage account (disabled by default for new accounts since Aug 2023; existing accounts untouched) |
Read the table top to bottom and one pattern is unmissable: a guessable address, a permissive principal, and one account-level control that — if you'd flipped it — would have made every per-bucket mistake harmless. The misconfigurations vary; the cure rhymes.
Why a public bucket is a CRITICAL finding
Now that the mechanism is clear, the severity call is forced. We rate a publicly listable bucket CRITICAL — every time, by impact. Not because it's exotic, not because the detection is clever, but because of what a public bucket actually is: a complete dataset sitting behind a door with no lock. Three properties stack to make that the only honest rating.
The blast radius is the entire dataset. There's no partial exposure. When listing is open, an attacker doesn't get a foothold they have to expand — they get every key and the right to download all of it. A SQL injection gives you one query at a time and a defender can rate-limit you; a public bucket gives you aws s3 sync s3://<bucket> ./loot --no-sign-request and the only ceiling is bandwidth. The exposure and the total compromise are the same event.
The "exploit" is a single anonymous request — no exploit, no credential, no skill. There is no chain to assemble. No CVE to weaponize, no credential to phish, no payload to tune. One unauthenticated GET against the listing endpoint returns the manifest; the next request pulls the file. We've spent careers learning to discount findings that require a long, fragile attack path — and we're right to. A public bucket has no path. That absence of friction is precisely what makes it worse than findings that look scarier on paper.
Discovery is passive and free. You don't get the usual grace period where a misconfiguration sits unnoticed because nobody thought to look. Object stores live in a global, guessable namespace, and the open ones are continuously indexed at internet scale — aggregators catalog hundreds of thousands of public buckets and billions of files, searchable by keyword and extension, with zero traffic to your origin. An exposed bucket isn't stumbled upon; it's enumerated, by automation, often within hours of going public.
And then the property that pushes it past "bad data leak" into "your-whole-cloud-is-gone": buckets are credential reservoirs. Engineers treat object storage as a junk drawer for "just files," and the just-files turn out to be the keys to everything else (Accenture's held its KMS master key and ~40,000 plaintext passwords; Booz Allen's held live SSH keys and data-center admin credentials — full accounts in the leak roster below). In both cases the bucket wasn't the prize — it was the map and the keys. So the path runs: dump the bucket, grep for .env / .pem / .tfstate / id_rsa, then authenticate as you. A storage misconfiguration converts into full account compromise. And if the bucket is public for write, it converts into something worse: overwrite the JavaScript a site serves from that bucket and you've turned a storage misconfig into a supply-chain attack under a trusted domain.
"It's common, so it's not serious" is the wrong instinct
Here's the reflex we have to kill explicitly, because it's the one that gets buckets downgraded in real triage meetings: open buckets are everywhere, this is a beginner mistake, surely it's not a top-tier finding. Frequency in the wild is not a discount on severity. Those are two different axes. How often a misconfiguration occurs tells you about prevalence — useful for deciding where to invest in guardrails. How much damage it does when present tells you about severity — the only thing that should drive the rating. We refuse to launder a finding's severity down just because it's embarrassingly easy to make or boringly easy to detect. The misconfiguration being trivial is an argument for fixing it today, not for caring about it less.
The data, without the theatre
You don't need a scare statistic to make this case — the impact argument above stands on its own — but it helps to ground it. IBM's Cost of a Data Breach 2024, drawn from real breaches at 604 organizations, found that cloud misconfiguration was the initial attack vector in 15% of breaches. Put that next to the other leading root causes and the framing snaps into focus: stolen or compromised credentials sat at 16%, phishing at 15%. Cloud misconfiguration — the category public buckets live in — is in the top tier of how breaches actually start, shoulder to shoulder with credential theft and phishing, the two threats every program already spends real money to counter. The global average breach cost in that report was USD 4.88 million (Zscaler's summary of the IBM 2024 report). That's context, not a countdown clock.
Severity isn't an academic label — it's a statement about ordering. A publicly listable bucket jumps the queue because the distance between "exposed" and "total disclosure" is zero. Run it through any sane prioritization lens — how exploitable, how exposed, how sensitive, how big the blast radius — and it maxes out every input. For the full reasoning, we've laid it out separately: why a publicly listable bucket ranks CRITICAL and jumps the queue. This is the same line we hold for the rest of the "public, no password" category. An exposed unauthenticated data store gets identical treatment — the path from unauthenticated Redis to remote code execution lands at CRITICAL for the same impact-on-compromise reasons. A leaked .env with production secrets sits there too. Different scanner, different surface, identical severity.
How attackers find and dump public S3 buckets
Finding your public buckets isn't a hack. It's recon — passive, free, automated, and searchable. Nobody is sitting in a dark room guessing your bucket names by hand; a cron job is doing it for the entire internet, all the time, and the results are searchable. Once you've seen how cheap discovery is, the "it's obscure, nobody will find it" defense dies on the spot.
Active discovery: bucket names are a guessable global namespace
Attackers start from your company name and bolt on the suffixes every ops team reaches for. Given acme, the wordlist writes itself:
acme-backups,acme-prod,acme-dev,acme-stagingacme-assets,acme-static,acme-media,acme-uploadsacme-logs,acme-db-dumps,acme-terraform-state
That last one — acme-terraform-state — is the tell. Predictable, environment-suffixed names don't just make brute-forcing trivial; they advertise the contents. The tooling that grinds through these lists is mature and boring, which is exactly why it works:
cloud_enum— feeds one keyword wordlist against all three clouds at once (S3, GCS, Azure), so a single run covers your entire multi-cloud footprint.- S3Scanner — probes S3-compatible APIs (AWS, GCP, DigitalOcean, DreamHost, Linode, Scaleway, custom endpoints) and reports the exact permission set per bucket: Read, Write, Read-ACP, Write-ACP, Full-Control. It reports those for both anonymous and authenticated principals on AWS, and anonymous permissions on the other S3-compatible providers. It doesn't just tell you a bucket exists — it tells you whether you can write to it.
lazys3andbucket_finder— classic name-permutation brute-forcers.s3enum— resolves candidate names over DNS instead of hitting the S3 API directly, which means it never shows up in your S3 access logs. Stealth by design.
Passive discovery: someone already indexed the open ones
The active workflow still touches your endpoints. The passive one doesn't touch you at all. GrayhatWarfare continuously crawls open cloud storage and makes the contents searchable by keyword, filename, or extension — for anyone. An attacker types your brand name, or backup, or a raw extension like .sql, .pem, or .bak, and gets a list of matching files in already-public buckets, with zero packets sent to your infrastructure. You'll see nothing in any log, because the recon happened against GrayhatWarfare's copy, not yours.
The scale tells you this is not a niche threat. In a March 2025 snapshot, GrayhatWarfare's index held roughly 470,600 open buckets — about 313,000 S3 buckets, 64,000 Azure containers, 81,000 Google buckets, and 9,000 DigitalOcean buckets — and indexed roughly 15.2 billion publicly accessible files (GrayhatWarfare, March 2025). That is not a list someone painstakingly hunted — it is the ambient background level of cloud storage that is simply open right now, catalogued and keyword-searchable, and it only grows. If one of yours is on it, your "obscure name" bought you nothing. Bucket names also leak directly: from Certificate Transparency logs, GitHub and GitLab code search, JavaScript source maps, and plain HTML/CSS asset references on your own site.
Anonymous listing and dump — the exact commands
Once a name lands, the attacker tests read access with no credentials at all. No login, no token, no exploit. Here is the full per-cloud playbook — read it as the literal command history of a data breach:
# AWS S3 — list, then recursively pull everything
aws s3 ls s3://acme-backups --no-sign-request
aws s3 sync s3://acme-backups ./loot --no-sign-request
# Raw HTTP works too — returns XML <ListBucketResult> of every key
curl -s https://acme-backups.s3.amazonaws.com/
# Google Cloud Storage (gsutil is the common recon idiom; gcloud storage is the modern CLI)
gsutil ls gs://acme-backups
gcloud storage ls gs://acme-backups
curl -s https://storage.googleapis.com/acme-backups
# Azure Blob — note the literal query string
curl -s 'https://acmestorage.blob.core.windows.net/backups?restype=container&comp=list'
The --no-sign-request flag is the whole game: it tells the AWS CLI to send the request without signing it with any credentials, exactly as an anonymous internet user. If the bucket grants s3:ListBucket to Principal "*", you get HTTP 200 and an XML <ListBucketResult> enumerating every <Key>; if it's private you get HTTP 403 <Error><Code>AccessDenied</Code>. Scanners fingerprint exactly those response bodies. The Azure equivalent returns <EnumerationResults><Blobs> only when the container access level is set to Container; GCS returns its own <ListBucketResult> when public.
That aws s3 sync ... --no-sign-request line is not a hypothetical. The same recursive-sync pattern is how roughly 106 million records walked out of Capital One in 2019 — once the attacker had a credential with broad S3 access, copying the data was a single command. There was no clever payload at the exfiltration step. There never is. Copying public data is a built-in feature of the official tooling.
The keys-in-bucket pivot: the bucket is the master key
Dumping the files is rarely the end. It's usually the start, because buckets are credential reservoirs. The first thing an attacker does after aws s3 sync is grep the loot for secrets:
grep -rIl -e 'AKIA[0-9A-Z]{16}' -e 'BEGIN PRIVATE KEY' ./loot
find ./loot \( -name '*.env' -o -name '*.pem' -o -name '*.jks' \
-o -name 'id_rsa' -o -name '*.tfstate' -o -name '*.sql' \
-o -name 'credentials' \) -print
This is where storage misconfigurations become full-estate compromise — the Accenture and Booz Allen contents are detailed in the leak roster below, but the shape is always the same: the "data leak" is the boring part; the prize is the keys that unlock everything else.
The same pivot runs in reverse, which is why this surface and credential exposure are inseparable. Attackers who hold valid cloud keys don't need a bucket to be publicly listable at all — they authenticate and dump private buckets directly. Those keys come from somewhere, and the fastest on-ramp is how those keys leak from an exposed .env file. We cover how those files get exposed in that post; here it's enough to know it's the most common route to authenticated bucket enumeration.
Write access turns a leak into a supply-chain attack
Everything above assumes read. Public exposure can be read and write, and S3Scanner checks both for a reason. A bucket that grants s3:PutObject to Principal "*" (or GCS objectAdmin to allUsers, or an Azure container with anonymous write) lets an attacker do far worse than copy your data — they can change it. If that bucket serves your site's JavaScript or static assets, the attacker overwrites a live .js file and injects a web skimmer that harvests payment data from every visitor, or plants malware, or replaces a software-update artifact. A storage misconfiguration becomes a defacement and supply-chain vector, executed under your own trusted domain.
One honest caveat: most of this is invisible to you
The reason these breaches run for months before anyone notices is that the abuse is frequently unlogged. S3 data events are off by default — out of the box, individual object-level GetObject calls aren't recorded in CloudTrail, so an anonymous mass-download leaves no trail unless you explicitly turned data events on. And when attackers move to presigned URLs (covered next), the signing material is redacted: S3 server-access logs and CloudTrail strip the X-Amz-Signature value, so even a recorded request doesn't reveal the credential that authorized it. Plan your defenses on the assumption that discovery and the first dump happened silently — because they almost certainly did.
The leak roster: what was actually inside
None of the leaks below was a hack. Nobody cracked a password, popped a shell, or chained a clever exploit. In every single case the bucket was public, no password — and someone simply pointed a URL at it and read what was there. The interesting question is never "how hard was it to get in?" because the answer is always "trivial." The interesting question is "what was inside?" — and the answer is consistently the worst possible payload an organization could leave in the open.
| Incident | Year | What was inside | Scale |
|---|---|---|---|
| US Army INSCOM / NSA | 2017 | Downloadable Oracle virtual appliance with classified disk partitions, private keys for distributed intelligence systems, hashed passwords — marked Top Secret / NOFORN | 47 viewable items at the inscom bucket |
| Accenture | 2017 | Plaintext master access key for Accenture's AWS KMS account (in acp-deployment), ~40,000 plaintext passwords in a DB backup (in acp-software), production VPN keys, private signing keys |
137 GB in the largest of four buckets |
| Booz Allen Hamilton / NGA | 2017 | Plaintext SSH private keys for a BAH engineer, admin credentials to a data-center OS, DoD geospatial-intelligence project data | Tens of thousands of files |
| Verizon / NICE Systems | 2017 | Customer names, addresses, phone numbers, and account PINs — exposed by a third-party call-center vendor | Verizon stated 6 million customers; UpGuard assessed up to 14 million |
| Pegasus Airlines EFB | 2022 | Flight data, crew PII, and Electronic Flight Bag source code containing plaintext passwords and secret keys | ~23 million files (~6.5 TB) |
| SEGA Europe | 2022 | Live AWS keys, MailChimp and Steam API keys, SNS service plus content-delivery config that could run scripts and upload files to SEGA domains, plus Football Manager forum PII | Multiple credential sets across one misconfigured bucket |
The two that best prove the point
The 2017 cluster came out of UpGuard's Chris Vickery, who wasn't running an exploit kit; he was checking which buckets responded to an anonymous request. The 2022 entries came out of routine web-mapping projects. The barrier to entry on every one of them was zero.
US Army INSCOM / NSA (2017). On September 27, 2017, a public S3 bucket at the subdomain inscom — tied to the US Army Intelligence and Security Command, jointly overseen with the NSA, and to the contractor Invertix — exposed 47 viewable files and folders. The crown jewel was an Oracle virtual appliance (ssdev, in .ova format) whose disk image held six partitions ranging from 1 GB to 69 GB, marked Top Secret and NOFORN — meaning the data couldn't legally be shared even with allied nations. Alongside it: private keys used to access distributed intelligence systems and hashed passwords, plus configuration artifacts from the Pentagon's Red Disk platform and the DCGS-A intelligence system. As UpGuard put it, the tools needed to potentially access the networks of multiple Pentagon intelligence agencies should not be available to anybody entering a URL into a web browser (per UpGuard's INSCOM writeup). No clearance was checked. No login prompt appeared. The misconfiguration was the breach.
Accenture (2017). This is the example to keep in your head whenever someone tells you a bucket "only" holds files. On September 17, 2017, four public S3 buckets — acp-deployment, acpcollector, acp-software, and acp-ssl, all under the account awsacp0175 — were found wide open; Accenture secured them the next day, September 18. The largest, acp-software, ran to 137 GB and held a database backup with nearly 40,000 plaintext passwords — but the size isn't the headline. Inside the acp-deployment bucket's "Secure Store" folder sat a plaintext document containing the master access key for Accenture's AWS Key Management Service account — the key that decrypts keys (per UpGuard's Accenture report). The acpcollector bucket carried VPN keys used in production. And client.jks keystores held private signing keys along with the passwords to decrypt them. At the time, Accenture counted 94 of the Fortune Global 100 as clients. A bucket anyone could read held the credentials to reach into all of it.
Accenture and Booz Allen are not outliers in this respect — they are the rule. The reason a public bucket so often becomes a full estate compromise rather than a contained data leak is that buckets are where organizations quietly stash the secrets that unlock everything else: database backups, .tfstate files, keystores, SSH keys, service-account JSON. The Booz Allen Hamilton / NGA leak is the compact version of the same lesson (per UpGuard's Booz Allen / NGA account): a public bucket, discovered May 22, 2017 and secured the next day, tied to Booz Allen's work for the National Geospatial-Intelligence Agency, containing plaintext SSH keys for one of its engineers and credentials granting administrative access to at least one data center's operating system. The one bright spot in the whole roster: after notification, the NGA secured it within nine minutes — proof that the fix is fast even when the detection was someone else's lucky URL.
The third-party angle, and why it isn't a 2017 problem
Verizon / NICE Systems (2017) earns its row for a different reason: it wasn't even Verizon's bucket. The exposed, fully downloadable verizon-sftp bucket belonged to NICE Systems, a third-party call-center vendor. It held customer names, addresses, phone numbers, and — most damaging — account PINs, the exact secret a social engineer needs to take over a mobile account. Verizon stated 6 million customers were affected; UpGuard assessed up to 14 million (per UpGuard's Verizon / NICE Systems writeup). Your attack surface includes buckets created by vendors, contractors, and the dev who spun up a quick storage account for a one-off export and forgot it.
It would be comforting to file leaky buckets under "things that happened before the defaults got better." They didn't stop. In January 2022, researchers disclosed a misconfigured public SEGA Europe bucket containing multiple live AWS key sets, plus MailChimp and Steam API keys, and an SNS service plus content-delivery configuration that could run scripts and upload files to SEGA-owned domains — alongside PII for hundreds of thousands of Football Manager forum members (per Threatpost's SEGA Europe coverage). The following month, on February 28, 2022, a web-mapping project found PegasusEFB's open, password-free S3 bucket holding roughly 23 million files — about 6.5 TB — including over 3.2 million files of flight data, more than 1.6 million files of crew PII, and Electronic Flight Bag source code with plaintext passwords and secret keys baked in; the same EFB software was sold to two other airlines, widening the blast radius (per SafetyDetectives' PegasusEFB report). Both were anonymous-access misconfigurations, years after every cloud provider knew better. The lesson is simple and uncomfortable: the contents are routinely catastrophic, the access is routinely free, and "just files" is the most expensive assumption in cloud security.
How to check if an S3 bucket is public — and why defaults won't save you
There are two honest ways to answer this question, and you need both. The first is to look at your bucket the way an attacker does — from the outside, with no credentials. The second is to query the cloud's own control plane and read the access configuration straight from the source. The first tells you what's exposed right now; the second tells you why, and which knob to turn. Skip the external check and you're trusting your own config to be honest with you, which is exactly the assumption that produced every breach in this article.
The defaults timeline — and the gap it leaves wide open
- AWS announced on December 13, 2022 that S3 would automatically enable Block Public Access and disable ACLs (Object Ownership =
BucketOwnerEnforced) for all new buckets, in every region including GovCloud and China, regardless of creation method. Rollout ran through April 2023. - Azure began disabling anonymous blob access by default for new storage accounts created from August 2023 (portal from September, API/CLI/Terraform/ARM from November). The new default sets
AllowBlobPublicAccessto false at the account level. - Google Cloud Storage Public Access Prevention is opt-in — you enforce it per-bucket or via an organization policy. It is not on by default.
Now the part nobody puts in bold: none of this retrofits anything. AWS says it explicitly — existing buckets are unchanged. Azure's new defaults apply only to accounts created after the cutoff. A bucket you spun up in 2019 with a public-read ACL is exactly as public today as the day you created it. The improved defaults are a fence built across the front of a field that already has a hundred open gates behind it. Automated indexers like GrayhatWarfare don't care when your bucket was created — they care that it's listable.
Prove it like an attacker: the external, no-credentials check
Run this first, because it's the ground truth. A public-read S3 bucket returns HTTP 200 with an XML <ListBucketResult> body enumerating every key; a locked one returns 403 with <Code>AccessDenied</Code>. That single distinction is the whole test.
# AWS S3 — does it list with no signature?
aws s3 ls s3://your-bucket-name --no-sign-request
curl -s https://your-bucket-name.s3.amazonaws.com/
# Google Cloud Storage
gcloud storage ls gs://your-bucket-name
curl -s https://storage.googleapis.com/your-bucket-name
# Azure Blob — the literal query string is required
curl -s 'https://youraccount.blob.core.windows.net/yourcontainer?restype=container&comp=list'
If S3 hands back a <ListBucketResult>, GCS the same, or Azure returns <EnumerationResults><Blobs>, the bucket is public and you're looking at the same XML an attacker uses to pick targets. There is no "but it's not indexed yet" reprieve — that XML is the index. Read 403 as good and 200-with-keys as a CRITICAL finding, full stop.
Audit from the control plane: AWS-native
External checks tell you the symptom; the API tells you the cause and catches buckets that block listing but leak in other ways. On AWS, three calls give you per-bucket truth:
# Is Block Public Access actually on for this bucket?
aws s3api get-public-access-block --bucket your-bucket-name
# Does AWS itself consider the bucket public? (reads the policy)
aws s3api get-bucket-policy-status --bucket your-bucket-name \
--query 'PolicyStatus.IsPublic'
# Any legacy AllUsers / AuthenticatedUsers grants on the ACL?
aws s3api get-bucket-acl --bucket your-bucket-name
If PolicyStatus.IsPublic comes back true, the bucket policy grants public access regardless of what the ACL says. For coverage across the whole account, lean on the managed tooling: IAM Access Analyzer for S3 surfaces every bucket exposed publicly or cross-account and names the exact source — ACL versus bucket policy versus access-point policy — so you fix the right layer. And wire in AWS Config managed rules so drift is caught continuously: rules such as s3-bucket-public-read-prohibited, s3-bucket-public-write-prohibited, and s3-account-level-public-access-blocks. The third is the one people forget — it flags the day someone turns off account-level Block Public Access, your single highest-leverage control.
Audit from the control plane: GCS and Azure
On Google Cloud Storage, the dangerous principals are allUsers (anyone, unauthenticated) and allAuthenticatedUsers — which, despite how it reads, means any Google account on Earth, not your organization. Check for both:
gcloud storage buckets get-iam-policy gs://your-bucket-name \
| grep -E 'allUsers|allAuthenticatedUsers'
Then let Security Command Center do the fleet-wide sweep — its Public bucket findings flag exactly these grants across every project. On Azure, start at the account level, because AllowBlobPublicAccess=false forces every blob request to be authorized regardless of any individual container's access setting:
# Account-level override — should be false
az storage account show --name youraccount --resource-group yourrg \
--query allowBlobPublicAccess
# Per-container access level — should be null/Off, not Container or Blob
az storage container show-permission --name yourcontainer \
--account-name youraccount --query publicAccess
Then enable Microsoft Defender for Cloud, whose anonymous-access recommendations flag any storage account or container that drifts back to public. Across all three clouds the pattern is identical: one account-level control, then per-resource verification, then a continuous service watching for drift.
Why point-in-time isn't enough
Every check above is a snapshot, and buckets don't stay the way you left them. A developer ships a one-line Terraform change to unblock a deploy — acl = "public-read", or an allUsers binding, or allow_blob_public_access = true — the day after your annual penetration test signs off clean. That bucket is now public, and it stays public for the next twelve months because nobody's looking again until the next test. Buckets get flipped to public between your annual pentests, not during them, which is the entire argument for treating external exposure as a continuously-monitored signal rather than an audit you do once a year. An attacker's enumeration runs every day. Your detection should too.
What FortWatch's external scan actually does — and what it doesn't
This is the one earned product mention in this article, and it comes with the honest scope, because overstating it would undercut the whole point. FortWatch runs this exact bucket enumeration on a schedule across S3, GCS, and Azure Blob — the attacker enumeration step described earlier, productized. It takes your asset domain, generates bucket-name candidates the same way an attacker permutes them — the base name plus suffixes like -backup, -prod, -uploads, -static, -logs, -db and prefixes like backup-, dev-, staging- — caps the run at around 100 probes in small batches, and hits each candidate against S3, Azure, and GCP, fingerprinting the public-listing markers <ListBucketResult> and <EnumerationResults>. A bucket that lists files is flagged CRITICAL; one that's listable but empty is flagged HIGH.
Now the limits, stated plainly, because this is the differentiator: the external pass detects buckets that are publicly listable by a name derived from your domain. In that recon mode it does not read your IAM or bucket-policy JSON; it does not catch a bucket that blocks listing but still serves individual objects to anyone who guesses the key; it does not find buckets whose names have nothing to do with your domain; and it does not inspect presigned-URL or SAS-token leakage. Those deeper checks — IAM least-privilege, policy auditing — come from optional read-only account linking, not the external name-guessing scan. So when you read the next sections on bucket-policy hardening and signed-URL hygiene, treat those as defenses you own. No external scanner — ours included — sees inside a non-listable bucket or knows where your presigned URLs ended up.
The signed-URL leak everyone forgets — presigned S3 and Azure SAS tokens
Lock down every bucket account-wide and you've closed the front door. But there's a side door almost no security guide treats seriously, and it's wide open in most production codebases right now: the presigned URL. You generate one to let a customer download an invoice or upload a profile photo without giving them AWS credentials. It works, it ships, everyone moves on. And then that URL ends up somewhere it shouldn't — and the bucket you so carefully made private hands its contents to a stranger anyway.
The single most important thing to understand, and the thing developers consistently get wrong, is what a presigned URL actually is. AWS says it in plain English: "presigned URLs are bearer tokens that grant access to those who possess them." It is a bearer token. Like a session cookie or an API key, possession is authorization. There is no second factor, no identity check. Whoever holds the URL can perform the operation it grants — download, upload, overwrite — for as long as it's valid. Azure's SAS (Shared Access Signature) tokens and GCS signed URLs work the same way: the signature lives in the query string, and the query string is the credential.
They are not single-use, and they inherit your permissions
Two myths kill people here. The first is that a presigned URL is consumed when it's used. It isn't. From the AWS docs: "You can use the presigned URL multiple times, up to the expiration date and time." Hand it to one person and it works for the next ten thousand who get a copy. The second myth is that the URL is scoped narrowly to the one file you had in mind. It is scoped to the file, yes — but it is signed with, and carries the full weight of, the IAM principal who created it. AWS again: "The credentials used by the presigned URL are those of the AWS Identity and Access Management (IAM) principal who generated the URL." Which is exactly why a long-lived, over-permissioned signer is so dangerous.
Expiry mechanics that turn a transient grant into a permanent backdoor
How long a presigned URL stays valid depends entirely on the credentials you signed it with, and the limits are not intuitive:
- IAM user credentials (SigV4): valid up to 7 days — that's
X-Amz-Expires=604800, the maximum. Sign with a long-lived access key and you can mint a URL that works for a full week. - The S3 console: caps you between 1 minute and 12 hours. Saner, but most leaks come from code, not the console.
- Temporary / role credentials: the URL dies when the credentials die, whichever comes first. AWS is explicit: "The presigned URL expires when the role session expires, even if you specify a longer expiration time." An
STS AssumeRolesession defaults to about 1 hour; EC2 instance-profile credentials rotate with a maximum validity of roughly 6 hours.
See the asymmetry? Sign with short-lived role credentials and even a leaked URL self-destructs in an hour. Sign with a baked-in IAM user key and set X-Amz-Expires to its maximum, and you've manufactured a durable, unauthenticated link to your data. The same trap exists on Azure: an account-key SAS with an expiry months or years out is a standing invitation, which is precisely why Azure pushes you toward user-delegation SAS — tokens signed with an Entra ID-derived key, themselves bounded by a short-lived credential rather than the storage account's master key.
How they actually leak — and why you won't see it in your logs
The full credential lives in a plain URL, and URLs go everywhere:
- Browser history and bookmarks — the signed link sits in history on every shared, kiosk, or family machine the user touches.
- HTTP
Refererheaders — load a page referencing a presigned URL, click a link to a third party, and the browser may ship the full signed URL to that third party in theReferer. Your customer's data, handed to an ad network, because of one outbound link. - CDN, proxy, and load-balancer access logs — every hop that logs the request path now has the bearer token in plaintext, often retained for months.
- Screenshots, Slack, and email — "hey can you grab this file?" pasted into a channel fifty people and three integrations can read.
- Committed code — a presigned or SAS URL hardcoded into a config, a fixture, a test, then pushed. Attackers grep public GitHub, GitLab, and pastebin for query strings containing
X-Amz-Signature,X-Amz-Credential, and Azure'ssig=— and if your secret scanning isn't doing the same, they're a step ahead.
The cruelest part is the forensics gap. S3 server-access logs and CloudTrail redact X-Amz-Signature, and S3 data events are off by default. So a leaked presigned URL can be used a thousand times to exfiltrate objects and, unless you went out of your way to enable data-event logging, there's no record of the signature, nothing in the bucket's own audit trail that screams "someone is draining this." This is also the boundary of what an external scanner can see — a signed-URL leak rides over otherwise-private storage and surfaces only in your CDN logs, your repos, and your code review. This one is yours to own.
So, are S3 presigned URLs secure?
Yes — exactly as secure as you treat a password, and no more. The mechanism is sound; the failure mode is always operational. AWS documents these directly in its presigned URL guidance:
- Mint them server-side, per request, with short-lived credentials. Generate the URL on your backend at the moment of need using role/STS credentials, not a baked-in IAM user key. The URL then inherits a ~1-hour death sentence for free. Never generate presigned URLs in client code or embed them in static pages and bundles.
- Keep expirations in minutes, not days. If a human is going to click it now, ten minutes is plenty. The 7-day maximum exists for niche cases; treat it as a smell, not a default.
- Enforce a signature-age ceiling with a bucket policy. The
s3:signatureAgecondition rejects any presigned request whose signature is older than N milliseconds, regardless of whatX-Amz-Expiresclaimed at minting time:
That value is{ "Version": "2012-10-17", "Statement": [{ "Sid": "DenyOldPresignedSignatures", "Effect": "Deny", "Principal": { "AWS": "*" }, "Action": "s3:*", "Resource": "arn:aws:s3:::company-data/*", "Condition": { "NumericGreaterThan": { "s3:signatureAge": "600000" } } }] }600000milliseconds — ten minutes, the exact figure AWS uses in its own example. Even a developer who fat-fingers a 7-day URL can't outrun this policy. - Pin the network path. Add an
aws:SourceIpcondition (public endpoint) oraws:SourceVpc/aws:SourceVpcecondition (VPC endpoint) so a leaked URL is useless from anywhere but your expected network range. - Front downloads with your own backend or a CDN with origin access control. Proxy the download through an endpoint you control, or use CloudFront with Origin Access Control against a fully-private bucket, so the storage credential never travels in a
Refererheader. - On Azure, prefer user-delegation SAS over account-key SAS, scope each token to the single blob and operation it needs, and keep the expiry tight. An account-key SAS is signed with the storage account's master key — revoking it means rotating that key for everyone.
- Treat signed URLs as secrets in CI. Run TruffleHog or Gitleaks with rules for
X-Amz-Signatureand SASsig=patterns so a committed token fails the build instead of seasoning your git history.
Bucket-level controls and signed-URL hygiene solve two halves of the same problem. Account-level Block Public Access stops a bucket from being world-readable; signed-URL discipline stops a private bucket from leaking object-by-object through tokens you handed out yourself. You need both. Skip the second and you've built a vault with a perfect lock and a stack of skeleton keys floating around Slack.
Locking it down: the account-level control per cloud
Here is the good news that breach lists and pentest write-ups almost never tell you: the fix for public object storage is not a project. Each of the three major clouds ships a single account-level control that overrides every per-bucket setting in one move, and once it's on, an engineer cannot accidentally flip a bucket public no matter how they edit a policy or paste an ACL. You don't have to chase down individual buckets. You throw one switch, then clean up the long tail behind it.
But before you flip anything — audit for legitimately public workloads first. The account-level switches are intentionally blunt instruments; that bluntness is the point. If you serve a static website, public marketing assets, or a downloads bucket directly from object storage with anonymous read, enabling the account-level control will take it offline. So don't do that. The correct architecture — on every cloud — is to keep the bucket itself private and serve public content through a CDN: CloudFront with Origin Access Control (OAC) on AWS, signed URLs or Azure Front Door / CDN on Azure, and a CDN or signed URLs on GCS. The CDN becomes the only public path; the bucket stays locked. Migrate those workloads to the CDN pattern first, confirm they still serve, then enable the switch. With that guardrail stated once, here's the exact lockdown per cloud — jump to yours.
AWS — account-level Block Public Access, then disable ACLs
S3 has four independent Block Public Access settings that override conflicting ACLs and bucket policies. Setting them at the account level is the single highest-leverage control you have on AWS, because it covers every current and future bucket and an account-level block cannot be overridden by a per-bucket policy. New buckets created after April 2023 ship with BPA on by default, but pre-2023 buckets and any account-wide gap are not retrofitted.
Inline guardrail: confirm no intentional public static site is fronted directly by a bucket before enabling. Migrate those to CloudFront + OAC first. Then:
aws s3control put-public-access-block \
--account-id <account-id> \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Next, kill ACLs entirely. Legacy AllUsers and AuthenticatedUsers ACLs were the mechanism behind the entire UpGuard-era roster. Set object ownership to BucketOwnerEnforced so ACLs are ignored and access is governed only by IAM and bucket policies:
aws s3api put-bucket-ownership-controls \
--bucket <bucket> \
--ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'
Least-privilege IAM plus IMDSv2 — the direct Capital One fix. Block Public Access does nothing for the SSRF-to-credentials path, because in that scenario the bucket was never public; an overprivileged role was. Scope every role and instance policy to specific buckets and actions — never s3:* on Resource "*". A WAF or reverse-proxy role should have zero S3 access; the Capital One WAF role had broad S3 access, and that, not any ACL, is what let 700+ buckets walk out the door. Then enforce IMDSv2 so a simple GET-based SSRF can't read role credentials from 169.254.169.254:
aws ec2 modify-instance-metadata-options \
--instance-id <instance-id> \
--http-tokens required \
--http-put-response-hop-limit 1
IMDSv2 requires a PUT to obtain a session token before any metadata read, which defeats the proxied/SSRF read pattern; the hop limit of 1 stops a container or proxy one hop away from reaching it. Since March 2024, AWS supports setting IMDSv2 as the account/region default for new instance launches, and AWS's own analysis of IMDSv2 against real-world SSRF says it blocks the vast majority of such cases. Turn that default on, and disable IMDSv1 on existing instances.
Hardening extras (do these too):
- Default SSE-KMS encryption so even a copied object is useless without the key.
- Deny non-TLS access with a bucket-policy condition on
aws:SecureTransport=false. - Versioning + MFA-delete so an attacker who gains write access can't silently overwrite or destroy objects.
- VPC gateway endpoints +
aws:SourceVpceconditions to keep data-plane traffic off the public internet where feasible.
GCS — uniform bucket-level access, then Public Access Prevention
Google Cloud's account-level control is Public Access Prevention. First disable per-object ACLs with uniform bucket-level access, then enforce PAP, which hard-blocks any grant of allUsers or allAuthenticatedUsers (remember: that second principal means any Google account on Earth, not your org).
Inline guardrail: audit existing public resources first so you don't break an intentionally-public workload. Then:
gcloud storage buckets update gs://<bucket> --uniform-bucket-level-access
gcloud storage buckets update gs://<bucket> --public-access-prevention=enforced
Once enforced, attempts to add allUsers/allAuthenticatedUsers to an IAM policy fail with 412 Precondition Failed, and any request authorized as those principals fails with 401 or 403 — existing grants are overridden. To make sure individual projects can't quietly opt out, set it once at the top via the organization policy constraint constraints/storage.publicAccessPrevention at the org, folder, or project level. See Google's reference on using Public Access Prevention for the org-policy syntax and the audit-first workflow.
Azure — disallow blob public access at the account level
Azure's equivalent is a single property on the storage account. Setting AllowBlobPublicAccess to false overrides every container's access level — whether a container was left at "Container" (list + read all blobs) or "Blob" (read a known blob) — and forces authorization on every blob request in one move.
Inline guardrail: audit for containers that intentionally serve public static content first, and move them behind a CDN / Front Door or issue scoped SAS tokens before disabling. Then:
az storage account update \
--name <account> \
--resource-group <resource-group> \
--allow-blob-public-access false
New storage accounts created since August 2023 default to anonymous access disallowed, but existing accounts must be remediated explicitly. Enforce it fleet-wide with the built-in Azure Policy "Storage accounts should prevent anonymous access," and for legitimate sharing, prefer Entra ID auth or scoped, short-lived SAS — ideally user-delegation SAS — over ever re-enabling public access. Microsoft's guide to remediating anonymous read access for blob data covers both the account property and the Azure Policy enforcement path.
Guardrails so it stays fixed
Flipping the switch is a point-in-time win; keeping it flipped is the discipline. Enforce no-public-buckets in CI/CD by running tfsec or Checkov (or an OPA/Conftest policy) against your Terraform before deploy, so a public ACL or an allUsers grant never reaches the apply step. Layer the native detectors on top — IAM Access Analyzer for S3, AWS Config rules, GCP Security Command Center "Public bucket" findings, Microsoft Defender for Cloud anonymous-access recommendations. And because buckets get flipped to public between annual pentests — usually by a one-line IaC change the day after the test — treat external bucket exposure as a continuously-monitored EASM signal, not a quarterly audit item.
Public cloud bucket FAQ
These are the questions people actually type into search engines and ask AI assistants about exposed object storage. Each answer is self-contained, so you can act on any one of them without reading the rest.
Does enabling Block Public Access break my static website or CloudFront?
No — not if you serve the site through CloudFront with Origin Access Control (OAC) and keep the bucket itself private. Account-level Block Public Access only breaks setups where the S3 bucket is the public endpoint (the legacy S3 static-website hosting model with a public-read bucket policy). With CloudFront + OAC, CloudFront authenticates to a private bucket on your behalf, so visitors never touch S3 directly and the bucket stays locked down. Migrate any static-site or public-asset buckets to CloudFront + OAC first, confirm the site still serves, then enable account-level Block Public Access.
How do I check whether my S3 bucket is publicly listable?
Test it the way an attacker would — with no credentials at all. Run aws s3 ls s3://<bucket> --no-sign-request. An HTTP 200 with a key listing means the bucket is publicly listable; a 403 AccessDenied means listing is locked. Raw HTTP works too: curl -s https://<bucket>.s3.amazonaws.com/ returns an XML <ListBucketResult> when s3:ListBucket is open. Then confirm from the inside with aws s3api get-public-access-block and aws s3api get-bucket-policy-status (returns .PolicyStatus.IsPublic), or use IAM Access Analyzer for S3. Note that "listing blocked" does not mean "private" — a bucket can deny ListBucket while still serving objects to anyone who guesses a key.
Does S3 Block Public Access apply to existing buckets?
Account-level Block Public Access applies to every current and future bucket the moment you enable it — it overrides any conflicting ACL or bucket policy across the whole account. That's why account-level enablement is the highest-leverage control: one command covers the entire fleet. Don't confuse this with the "on by default" change AWS rolled out in April 2023, which only covers newly created buckets — pre-2023 buckets are not retrofitted. The long tail of older buckets, the ones from the UpGuard era that still carry a legacy AllUsers ACL, stay exactly as exposed until you explicitly enable account-level BPA.
Can a public bucket be written to or deleted by anyone, not just read?
Yes — if the bucket is public for write, anyone can upload, overwrite, or delete objects. This happens when a policy grants s3:PutObject to Principal "*", a GCS bucket grants objectAdmin to allUsers, or an Azure container is set to a write-enabled public level. Write access is arguably worse than read: an attacker who can overwrite the JavaScript or static assets a site serves from a bucket can inject a web skimmer, plant malware, or tamper with software-update artifacts — turning a quiet storage misconfiguration into a supply-chain attack under your own trusted domain. Always check write permissions, not just read, and enable versioning plus MFA-delete so a malicious overwrite can be rolled back.
What does allUsers mean on a Google Cloud Storage bucket?
allUsers means anyone on the internet, fully unauthenticated. Granting it makes the bucket — or specific objects — readable (or writable) by the entire public, no Google account required. The dangerous sibling is allAuthenticatedUsers, frequently misread as "people in my organization." It actually means any Google account on earth — a free Gmail signup is enough — so for practical purposes it is effectively public too. Public Access Prevention blocks both: once enforced, requests authorized as either principal fail with HTTP 401 or 403, and any attempt to add them to an IAM policy fails with 412 Precondition Failed.
Are S3 presigned URLs secure, and what happens if one leaks?
Presigned URLs are bearer tokens — anyone who holds the URL can use it, repeatedly, until it expires. They are not single-use, and they carry the permissions of the IAM principal who minted them. A URL that leaks via an HTTP Referer header, a CDN or proxy log, a screenshot, a Slack message, or a git commit grants that same access to whoever finds it. Keep expirations to minutes; mint URLs server-side per request from short-lived STS credentials so they die in roughly an hour; add an s3:signatureAge bucket-policy condition to reject old signatures; and never embed them in static pages or client bundles. Be aware that S3 data events are off by default and the signature is redacted from logs, so presigned-URL abuse is often unlogged — prevention beats detection here.
How do I make an Azure Blob container private and disable anonymous access?
Set AllowBlobPublicAccess=false at the storage-account level — one move that overrides every container's access level at once.
az storage account update \
--name <account> \
--resource-group <rg> \
--allow-blob-public-access false
This forces authorization on every blob request regardless of whether an individual container is set to the Container or Blob public level, closing the whole account in a single command. New storage accounts created since August 2023 ship with anonymous access disabled by default, but existing accounts must be remediated explicitly. Enforce it fleet-wide with the Azure Policy "Storage accounts should prevent anonymous access," and for legitimate sharing use a short-lived SAS — preferably a user-delegation SAS signed with Entra ID — rather than flipping a container public.
How do I prevent public buckets across an entire AWS account or GCP organization?
Set the guardrail at the highest scope you can, so no individual bucket or project can opt out. On AWS, enable account-level Block Public Access with all four settings turned on:
aws s3control put-public-access-block \
--account-id <account-id> \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
On GCP, enforce the constraints/storage.publicAccessPrevention organization policy at the org or folder level so no project can ever grant allUsers or allAuthenticatedUsers:
gcloud storage buckets update gs://<bucket> --public-access-prevention=enforced
...and pin it as an org policy constraint so the per-bucket setting can't be relaxed. Then stop drift at the source: run tfsec or Checkov on your Terraform in CI so a one-line change that flips a bucket public is rejected before it ever deploys. The account-level control sets the floor; the CI gate keeps it there.
What do I do with this?
Everything above is theory until you turn it into a closed bucket. Here is the ordered checklist, highest-leverage first. The early steps are account-level controls that protect every current and future bucket in one move; the later steps are the hygiene the account-level control can't see.
AWS: flip the account-level switch, then close the legacy doors
- Audit for intentionally-public buckets first, then enable account-level Block Public Access. List which buckets are public on purpose — almost always static-website buckets — and migrate those to a private bucket fronted by CloudFront with Origin Access Control. Then turn on all four settings at the account level, which overrides any conflicting bucket policy or ACL on every current and future bucket:
New buckets since April 2023 ship with this on by default, but pre-2023 buckets and any account-wide override were never retrofitted — that long tail is where the legacy leaks live. This is why a publicly listable bucket ranks CRITICAL and jumps the queue: one command removes a whole class of total-disclosure exposure.aws s3control put-public-access-block \ --account-id <account-id> \ --public-access-block-configuration \ BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true - Kill legacy ACLs by setting Object Ownership to BucketOwnerEnforced. This removes ACLs from the equation entirely, so access is governed only by IAM and bucket policy and nobody can re-grant
AllUsersvia an ACL footgun:aws s3api put-bucket-ownership-controls --bucket <bucket> \ --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]' - Enforce IMDSv2 and audit every role with broad S3 access. The direct Capital One fix. IMDSv2 requires a session-token
PUTbefore metadata can be read, defeating the SSRF-to-credentials pattern that pulled roughly 700 buckets out of one EC2 instance:
Then hunt overprivileged roles — especially WAF, reverse-proxy, and app-server roles. A WAF role should have zero S3 access; never grantaws ec2 modify-instance-metadata-options --instance-id <id> \ --http-tokens required --http-put-response-hop-limit 1s3:*onResource "*".
GCS: uniform access, then enforce Public Access Prevention org-wide
- Turn on uniform bucket-level access, then enforce Public Access Prevention. Uniform access disables per-object ACLs; PAP hard-blocks any grant of
allUsersorallAuthenticatedUsers(any Google account on earth — not your org). Once enforced, reads as those principals fail 401/403 and attempts to add them fail with412 Precondition Failed:
Set it once at the top with thegcloud storage buckets update gs://<bucket> --uniform-bucket-level-access gcloud storage buckets update gs://<bucket> --public-access-prevention=enforcedconstraints/storage.publicAccessPreventionorganization-policy constraint so individual projects can't opt back into exposure. As on AWS, audit any intentionally-public workloads before enforcing org-wide.
Azure: disallow blob public access on every storage account
- Set AllowBlobPublicAccess to false on every storage account. This single property overrides every container's access level and blocks all anonymous access in one move, regardless of whether a container was left at the
ContainerorBloblevel:
Accounts created since August 2023 default to disallowed, but existing accounts must be remediated by hand. Enforce it with the Azure Policy "Storage accounts should prevent anonymous access," and use SAS or Microsoft Entra ID auth for legitimate sharing instead of public containers.az storage account update --name <account> \ --resource-group <rg> --allow-blob-public-access false
Verify the lockdown like an attacker
- Re-run the exact anonymous commands an attacker runs. Defaults and policies lie; the wire format doesn't. Against your known bucket and account names, confirm none of them list:
A public bucket answers# AWS — lists every key if s3:ListBucket is public aws s3 ls s3://<bucket> --no-sign-request # GCS — XML ListBucketResult if public gcloud storage ls gs://<bucket> # Azure — EnumerationResults XML if container access level is 'Container' curl -s 'https://<account>.blob.core.windows.net/<container>?restype=container&comp=list'HTTP 200with a<ListBucketResult>or<EnumerationResults>; a locked one answers403 AccessDenied. If your switches are set correctly, you should see only 403s.
Close the gaps the account-level control can't see
- Audit signed and SAS URLs as a distinct exposure class. Block Public Access does nothing to a presigned URL — it's a bearer token, reusable until it expires, not single-use. Shorten expirations to minutes; mint them server-side per request; prefer URLs generated by short-lived STS/role credentials so they die in about an hour; and grep your repos and history for leaked ones:
Rotate anything you find and shorten thegrep -rIn -e 'X-Amz-Signature' -e 'X-Amz-Credential' -e '[?&]sig=' .X-Amz-Expireson whatever generated it. - Secret-scan bucket contents and rotate anything exposed. After any audit or dump, treat the bucket as a credential reservoir, because that's what they usually are — Accenture's held the plaintext master key to its KMS account, with a separate backup carrying nearly 40,000 plaintext passwords; Booz Allen's held an engineer's SSH keys and data-center admin credentials. Run TruffleHog or Gitleaks over the contents and grep for the obvious payloads:
Rotate every key, password, and certificate you find — assume anything in a once-public bucket is already compromised. On the flip side, this is the same secret-scanning habit that catches a leaked .env file before it becomes the keys to every bucket you own, so it pays off in both directions.grep -rIE 'AKIA[0-9A-Z]{16}|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY' . find . \( -name '*.env' -o -name '*.tfstate' -o -name '*.sql' -o -name 'id_rsa' \)
Make it continuous
- Turn on native detection and add IaC guardrails. A bucket made public by a one-line Terraform change the day after a review stays exposed until someone notices — which is precisely why buckets get flipped to public between your annual pentests, not during them. Enable IAM Access Analyzer for S3, AWS Config managed rules (such as
s3-bucket-public-read-prohibited,s3-bucket-public-write-prohibited,s3-account-level-public-access-blocks), GCP Security Command Center's "Public bucket" findings, and Microsoft Defender for Cloud's anonymous-access recommendations. Then push the check left: runtfsecorCheckovin CI so a one-line IaC change can't reopen exposure between reviews. Treat external bucket exposure as a monitored signal, not a quarterly audit item — your buckets are external attack surface whether or not you remember they exist.
One boundary worth restating: this checklist is about a bucket leaking its contents. A dangling subdomain whose CNAME still points at a deleted S3 static-site endpoint is a different problem — that's a takeover, not a data leak, covered in an abandoned subdomain still pointing at a deleted S3 website endpoint. Don't conflate the two.
Step 6 — guessing bucket names and probing them anonymously — is the one part of this list that scales better as a continuous job than a manual one. That's exactly what FortWatch's external bucket scan across all three clouds does: connect a domain and it generates bucket-name candidates from it — base variations plus suffixes like -backup, -prod, -uploads, and -logs, plus prefixes like backup- and staging- — then probes each cloud for the public <ListBucketResult> and <EnumerationResults> markers and flags anything that lists. Publicly listable with files is CRITICAL; listable-but-empty is HIGH. It's the precise attacker reconnaissance from this article, run on a schedule instead of the morning you happen to remember. The external scan finds publicly listable buckets by name. IAM-policy and signed-URL hygiene are yours to own — or covered by optional read-only account linking. Name-guessing from the outside cannot audit your bucket-policy JSON, cannot find buckets whose names don't derive from your domain, and cannot see objects readable by direct key while listing is disabled. Those defenses are steps 1 through 9 above.
If you want the external half handled for you — including the CVE, exposed-file, and web-vuln coverage from broader vulnerability scanning that rides alongside storage exposure — start a free trial and connect a domain, and FortWatch will run the bucket enumeration across all three clouds on a schedule: the one part of this list that scales better as a job than a habit.

