LEAKCADE

How to find out if your public S3 bucket is leaking user files

Updated 8 min read

A public S3 bucket lets anyone on the internet read your files, and see every file name if listing is on. Check from outside with curl, turn on S3 Block Public Access, and serve private files through presigned URLs, which are links your server signs and that expire after a few minutes. R2, Google Cloud Storage and Supabase buckets follow the same rules.

QUICK CHECKLIST

  • Run curl against your bucket URL from outside your account
  • Turn on S3 Block Public Access for the account and the bucket
  • Remove public-read ACLs and policy statements open to everyone (*)
  • Serve private files with presigned URLs that expire in minutes
  • Use random keys under a per-user prefix
  • Disable r2.dev when a custom domain fronts your R2 bucket

What a public S3 bucket exposes

A bucket is a folder of files in the cloud, reachable by URL. Make it public and anyone with the URL can fetch what's inside. Turn on bucket listing too and they don't even need the file names, because the bucket hands them over.

That's how a public S3 bucket data leak usually happens. Your app stores avatars, PDFs and exports in one bucket. The fastest way to make images show up in the browser is to make the bucket public, so that's the setting your AI tool writes. The same setting now covers the passport scan a user uploaded last week.

How an open bucket gets emptiedAn attack path in four steps. A visitor reads the bucket name from your page, the bucket allows public listing, the root URL lists every file key, and a script downloads all the files.SITEPage source showsbucket URLBUCKETPublic ACL orpolicy allowslistLISTRoot URL returnsevery keyLEAKFiles downloadedin a loopSITEPage source shows bucket URLBUCKETPublic ACL or policy allows listLISTRoot URL returns every keyLEAKFiles downloaded in a loop
The bucket name is in your page source, so the bucket's own settings are the only protection.

Two switches matter here. Object read lets someone download a file when they know its key, the file's full name in the bucket, such as users/42/avatar.png. Listing lets them ask for every key. Listing is worse, but object read alone still leaks anything with a guessable name.

How an open bucket gets found and emptied

Open your live site and watch the Network tab. The bucket name is in every image URL, like yourapp-uploads.s3.amazonaws.com. People also guess names such as yourapp-prod and yourapp-backups.

With the name in hand, a stranger requests the bucket root. If listing is on, S3 answers with an XML list of keys, up to 1,000 per request, and a script follows the pages. If listing is off, they try file names. Keys like invoice-1001.pdf fall to a loop that counts upward.

Leaked credentials are a separate way in. An AWS key shipped in frontend code or committed to git opens a private bucket just as well, so read leaked API keys too. If your app accepts uploads, insecure file uploads covers what people can put in.

Cloudflare R2 public bucket, GCS and Supabase compared

R2 buckets are never public by default and need explicit permission, according to Cloudflare's docs. A Cloudflare R2 public bucket goes public in one of two ways:

Cloudflare also notes that public buckets currently don't list their contents at the root of your domain. Any file with a guessable key is still readable.

On Google Cloud Storage, granting allUsers the Storage Object Viewer role makes every object readable. That role includes the list permission, so it also turns on bucket listing. Storage Legacy Object Reader reads objects without listing. Google also offers public access prevention, which stops a bucket from being shared publicly at all.

Supabase public buckets serve files at /storage/v1/object/public/<bucket>/<path> with no login. Private buckets need policies and signed URLs, which Supabase storage policies walks through.

S3R2GCS
Private by default?New buckets, yes. Check old onesYesYes, until you grant allUsers
What makes it publicPublic ACL or bucket policyr2.dev URL or custom domainallUsers IAM binding or public object ACLs
ListingAllowed if list is grantedNot at domain rootObject Viewer role includes list
Safety netBlock Public AccessDisable r2.dev, add Access or WAFPublic access prevention
Temporary linksPresigned URLsPresigned URLs, S3 API domain onlySigned URLs

Check each provider's current docs for exact defaults.

The AI-written Terraform that opens a bucket

Infrastructure files are where a public S3 bucket often starts. Search your repo for acl = "public-read", block_public_acls = false, a bucket policy with Principal set to *, and in CDK, publicReadAccess: true. An ACL (access control list) is an older S3 permission list, and a public-read ACL on a bucket also allows listing.

To make an ACL work on a new bucket you have to turn ACLs back on. An aws_s3_bucket_ownership_controls block set to BucketOwnerPreferred nearby is a red flag.

BEFOREPublic-read ACL, protections off
resource "aws_s3_bucket" "uploads" {
  bucket = "my-app-uploads"
}

resource "aws_s3_bucket_ownership_controls" "uploads" {
  bucket = aws_s3_bucket.uploads.id
  rule {
    object_ownership = "BucketOwnerPreferred"
  }
}

resource "aws_s3_bucket_public_access_block" "uploads" {
  bucket                  = aws_s3_bucket.uploads.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_acl" "uploads" {
  depends_on = [aws_s3_bucket_ownership_controls.uploads]
  bucket     = aws_s3_bucket.uploads.id
  acl        = "public-read"
}
AFTERBlock Public Access on, ACLs disabled
resource "aws_s3_bucket" "uploads" {
  bucket = "my-app-uploads"
}

resource "aws_s3_bucket_ownership_controls" "uploads" {
  bucket = aws_s3_bucket.uploads.id
  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_public_access_block" "uploads" {
  bucket                  = aws_s3_bucket.uploads.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
AI-written config often turns Block Public Access off and adds a public-read ACL. Turn all four settings on and enforce bucket-owner ownership. Removing the ACL resource alone does not reset an existing ACL on AWS, so run the put-bucket-acl step too.

Newer buckets get Block Public Access by default, but an explicit false in your config switches it back off. Older buckets may differ, so check the setting and the current AWS docs.

How to lock down a public S3 bucket, step by step

Do these in order. If your site loads images straight from this bucket, step 1 will break them. Serve public assets through a CDN in front of the bucket, or move them to a separate bucket you've reviewed on purpose.

  1. Turn on S3 Block Public Access

    Enable all four settings on the bucket first.

    aws s3api put-public-access-block --bucket YOUR_BUCKET \
      --public-access-block-configuration \
      BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

    Then run aws s3control put-public-access-block with the same configuration and --account-id YOUR_ACCOUNT_ID so future buckets are covered. Account-level settings hit every bucket, so move intentionally public assets behind a CDN first.

  2. Remove the old public grants

    Delete bucket policy statements that grant * as the principal without a restricting condition. Reset the bucket ACL to private, then set Object Ownership to bucket owner enforced, which disables ACLs.

    aws s3api put-bucket-acl --bucket YOUR_BUCKET --acl private
    aws s3api put-bucket-ownership-controls --bucket YOUR_BUCKET \
      --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'

    On GCS, remove the allUsers and allAuthenticatedUsers bindings. On R2, disable the r2.dev URL if you don't need it.

  3. Serve private files with presigned URLs

    Sign a link on the server, only after you've checked the logged-in user owns the file. Take userId from the session, never from the request. That's the same ownership check as in IDOR in API routes.

    import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"
    import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
    
    const s3 = new S3Client({ region: process.env.AWS_REGION })
    
    // Call this only after you have checked who the user is
    export async function downloadUrl(userId: string, fileId: string) {
      return getSignedUrl(
        s3,
        new GetObjectCommand({
          Bucket: process.env.S3_BUCKET!,
          Key: `users/${userId}/${fileId}`,
        }),
        { expiresIn: 300 } // 5 minutes
      )
    }
  4. Use random keys under a user prefix

    Name files like users/<userId>/<crypto.randomUUID()>.pdf. Random keys make guessing impractical, but they only slow down someone who learns a key from a log or a shared link. The bucket still has to be private.

  5. Tighten CORS

    Set CORS rules only if browsers call the bucket directly, for example uploading to a presigned PUT URL. List your own origins instead of *. CORS only governs browsers, so curl ignores it. See CORS misconfiguration.

Treat a presigned URL like a password. Cloudflare's docs call them bearer tokens: anyone holding the URL can do the allowed operation until it expires.

Test it yourself

First, find your bucket URLs. Open your deployed app, open DevTools, go to Network and reload. Look for hosts containing s3.amazonaws.com, storage.googleapis.com, r2.dev or supabase.co/storage.

Then run these from a terminal. Use a key you know exists for the file checks, because a missing key can return a different status than a private one.

# S3: can anyone list it?
curl -s https://YOUR-BUCKET.s3.amazonaws.com/ | head -c 600
aws s3 ls s3://YOUR-BUCKET --no-sign-request

# S3: can anyone read a private file?
curl -sI https://YOUR-BUCKET.s3.amazonaws.com/users/123/passport.jpg | head -n 1

# GCS: list and read
curl -s "https://storage.googleapis.com/storage/v1/b/YOUR-BUCKET/o?maxResults=5"
curl -sI https://storage.googleapis.com/YOUR-BUCKET/users/123/passport.jpg | head -n 1

# R2 (no listing at the root, so use a known key)
curl -sI https://YOUR_R2_PUBLIC_HOST/users/123/passport.jpg | head -n 1

# Supabase: a 200 means the file is public
curl -sI https://YOUR_PROJECT.supabase.co/storage/v1/object/public/uploads/users/123/passport.jpg | head -n 1

A bad result looks like this:

A good result looks like this:

If you get a redirect on S3, use the regional URL https://YOUR-BUCKET.s3.REGION.amazonaws.com/.

Fix prompt you can paste

Paste this into Cursor or Claude Code at the repo root. It stops after the audit so you can review the list. Read the diff before you accept it, because it edits infrastructure files.

FIX PROMPT
Audit this repo for publicly exposed cloud storage. Search Terraform, CDK, CloudFormation, scripts and app code for: acl = "public-read", ACL: "public-read", block_public_acls = false, block_public_policy = false, restrict_public_buckets = false, Principal set to * in bucket policies, publicReadAccess: true, allUsers or allAuthenticatedUsers on GCS, public access on r2.dev, and Supabase buckets created with public: true. Also search app code for getPublicUrl calls, hardcoded bucket URLs, PutObjectCommand calls that pass an ACL, and any route that builds a file URL from a userId or fileId taken from the request.

For each hit, print file:line, the exact line, the provider and what it exposes (list, read or write). Stop and show me the full list before changing anything.

After I approve, fix it: turn on all four S3 Block Public Access settings, remove public ACLs and unconditional wildcard principals, and serve private files through server-side presigned URLs (expiresIn 300 or less) generated only after checking that the logged-in user owns the file. Build keys like users/<userId>/<randomUUID>. Restrict CORS to our own origins. Keep genuinely public assets in a separate bucket and list them for me.

Explain every change in plain English. Use placeholders, never real secrets.

Storage is one item on the vibe coding security checklist. To scan the whole app, run it through Leakcade.

Further reading

  1. Cloudflare R2 docs: Public buckets
  2. Cloudflare R2 docs: Presigned URLs
  3. Google Cloud Storage docs: Make data public

FAQ

How do I check if my S3 bucket is public?

Request the bucket root with curl -s https://YOUR-BUCKET.s3.amazonaws.com/ and run aws s3 ls s3://YOUR-BUCKET --no-sign-request. A list of keys means listing is public. Then request a private file by URL. A 200 means object read is public too.

What does S3 Block Public Access do?

It's a set of four settings that override public ACLs and public bucket policies. BlockPublicAcls rejects new public ACLs, IgnorePublicAcls ignores existing ones, BlockPublicPolicy rejects new public bucket policies, and RestrictPublicBuckets limits access under an existing public policy to AWS services and users in your own account. You can apply them to one bucket or to the whole account.

Is a Cloudflare R2 public bucket safe?

It is fine for files meant for everyone, such as logos. Any file with a guessable key is still readable, so keep user uploads in a private bucket. Cloudflare says the r2.dev URL is for development, so use a custom domain in production and disable r2.dev if you add WAF or Cloudflare Access.

Are presigned URLs secure?

They grant one operation on one object until they expire. Anyone who has the URL can use it, so Cloudflare calls them bearer tokens. Use short expiry times and only create them after checking the user's permission.

Does CORS protect my bucket from public access?

No. CORS tells browsers which websites may read responses. curl and scripts ignore it, so a public bucket stays readable. Fix the bucket permissions first, then set CORS to your own origins.

Want a second pair of eyes?

We review your whole repo and write a fix prompt for every finding. $19, report within an hour.

Check my repo

Leakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.