Skip to content
Back to field notes
SupabaseJune 6, 20266 min read

Supabase storage bucket visibility: public vs private before launch

A public Supabase storage bucket serves its objects to anyone with the URL and no auth, so user avatars, ID documents, and invoices can be world-readable. This note helps you decide which buckets to close before you share the launch URL.

SupabaseStorageAccess control

A public bucket serves user files to anyone with the URL

The concrete risk: a Supabase storage bucket marked public serves every object inside it over a predictable public path, with no authentication. If that bucket holds user-uploaded private files, anyone who has or can construct the URL gets the file. That includes avatars, but it also includes the things that hurt when they leak, ID documents, signed contracts, invoices, medical uploads, and private media.

Supabase storage buckets are either public or private, and that one flag decides the access model.

This is an authorization question (who is allowed to read this object), not a broken login. The public-vs-private distinction is the boundary, and it is set per bucket.

  • A public bucket exposes objects at a stable public URL under your project, of the form https://your-project.supabase.co/storage/v1/object/public/bucket-name/path/to/file. A GET to that URL returns the file to anyone, with no token, no anon key required, no session.
  • A private bucket does not serve objects at the public path. Reaching an object requires either a signed URL (a time-limited link you generate server-side) or an authorized request that passes the storage access policies.

Worth separating two different things up front. A public bucket is readable by design with no auth at all. A private bucket with guessable object paths is a weaker, different problem: the object still needs an authorized request or a signed URL to come back, so a guessed path alone does not hand over the file. Public means the door is open; guessable paths means the lock is cheap. This note is about the open door.

Why AI-built apps end up with a public bucket

When you ask an AI tool to add file upload to an app, the fastest path that produces a working demo is a public bucket. A public bucket means the uploaded file renders immediately from its public URL with zero extra code, no signed-URL generation, no policy to write. So the generated setup marks the bucket public, the avatar shows up on the first try, and the feature looks finished.

That is exactly the trap. The feature working in the browser tells you the file is reachable. It does not tell you who else can reach it.

  • Public is the convenient default for a scaffold because it removes the signed-URL step entirely.
  • The app displaying the file proves it is served, which hides that it is served to everyone.
  • One bucket often holds mixed content. A bucket created for public marketing images gets reused for user uploads, and now private files inherit the public flag.
  • Fast deploy means the public URL exists before anyone reviews which buckets are public and what is sitting in them.

Check each bucket before launch

You can confirm this on your own project without attacking anything. Use both the dashboard view and the public surface.

From the Supabase dashboard: open Storage and look at the bucket list. Each bucket shows whether it is public. A bucket marked public that contains anything user-specific or private is the suspicious result. Open the bucket and look at what is actually inside, not just what it was named for.

From the public surface: take an object you uploaded as a normal user and try to fetch it with no auth. Copy its public path and run a plain GET against https://your-project.supabase.co/storage/v1/object/public/bucket-name/path with no apikey header and no session, for example from a fresh private browser window or a curl with no headers. Do this only against your own project.

  • Suspicious result: the file comes back over the public path with no auth, and it is a file that should require a logged-in owner. That bucket is public and is leaking private objects.
  • Safer result: the public path returns a not-found or an error for a bucket that should be private, and the file only comes back through a signed URL or an authorized request. That is the expected behavior for a private bucket.
  • Repeat per bucket. The public flag is per bucket, so one closed bucket tells you nothing about the next. Also check the bucket that backs each upload feature, not only the ones you remember creating.

Make sensitive buckets private and serve via signed URLs

The fix is to match the bucket's visibility to the sensitivity of what it holds, then serve private files through short-lived signed URLs.

Decide per bucket whether its contents are genuinely public. Public is fine for assets that are meant to be seen by everyone with no account, such as marketing images, public logos, or open documentation files. For anything tied to a specific user or account, set the bucket to private.

For private buckets, serve objects with signed URLs generated server-side. A signed URL is a time-limited link that grants access to one object for a short window, after which it expires. Generate it on the server for the authenticated owner, set a short expiry, and never bake a long-lived link into the client. Pair that with storage access policies so the underlying authorized-request path also enforces who can read and write each object.

  • Flip user-private buckets to private first. That alone closes the no-auth public path.
  • Serve private objects through short-lived signed URLs generated for the authenticated user, not through the public path.
  • Set storage access policies on the bucket so authorized requests are scoped to the right user, the same way row-level rules scope table reads.
  • Do not rely on long, random object paths as your only defense. Unguessable paths are not access control; the visibility flag and the policies are.

Making a bucket private fixes the no-auth public read. It does not, by itself, decide who among your authenticated users may read which object, that is what storage access policies are for. And switching a bucket to private can break any front-end that was loading files from the old public path, so update those call sites to request signed URLs as part of the same change.

Where VibeCodeGuard fits, and what it does not decide

VibeCodeGuard's Launch Check probes your Supabase storage endpoints from the public surface and surfaces buckets that return assets without authentication. If an object comes back over the public path with no auth, the scan flags that bucket as publicly readable, with severity and fix direction. It reports what an outside visitor can actually reach with no account.

What the scan does not do is decide whether a given file should be public. A public bucket of marketing images is correct; a public bucket of user invoices is a leak. The scan tells you what is reachable; whether that is intended is your call, and it pairs with a manual review of which buckets back which upload features.

Next step: list your storage buckets, and for each public one, open it and confirm everything inside is genuinely meant for everyone. Flip the rest to private and switch their callers to signed URLs, then run a focused Launch Check against the public URL before you share it.

A clean Launch Check on storage does not prove every object is correctly scoped. The scan sees what is reachable from outside with no auth; it does not read your storage access policies or confirm that your private buckets are scoped to the right users. Treat a flagged public bucket holding private files as a launch blocker, and treat a clean result as one signal, not a guarantee. For per-user authorization on objects, review your policies directly.

> launch check

Scan the public surface before launch.

Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.