Field note
Supabase anon can list your storage buckets
listBuckets() returns every bucket's name and public flag when the publishable key can select storage.buckets. That catalog is a different leak from a public object URL.
A public object URL and a bucket catalog are different responses. The URL fetches one file because the bucket is public, or because a policy allows that read. The catalog is the list of buckets itself: names, whether each one is public, size limits, and allowed MIME types. The publishable key in your Next.js bundle is enough to ask for that list when storage.buckets allows it.
Supabase's JavaScript reference describes `listBuckets()` as retrieving the details of all Storage buckets in the project. The storage schema is where those details live. storage.buckets holds id, name, the public boolean, file_size_limit, allowed_mime_types, and owner_id. storage.objects holds the files. This post is the bucket rows. The public bucket post is the object URL and the object listing that can follow.
The free scan reports a 200 from the bucket endpoint as "Anon can list storage buckets." It does not download file bytes.
What a 200 from the bucket endpoint means
The Storage API serves that catalog at GET /storage/v1/bucket. The browser client calls it with the same publishable key it uses for everything else, in the apikey header and as the bearer token. There is no separate "list my buckets" secret.
curl -sS \ "$SUPABASE_URL/storage/v1/bucket" \ -H "apikey: $ANON_KEY" \ -H "Authorization: Bearer $ANON_KEY"
Run that against your own project URL and your own publishable key. A 401 or a 403 means this key cannot read the catalog. A 200 with a JSON array means it can. Each element is a bucket row: the name you would pass to storage.from(...), and the public flag the dashboard shows.
That array is useful to an attacker and useful to you for the same reason. It is the map. A private bucket still appears by name if the select on storage.buckets allows anon. The name is the id you need before you can try list(), a public URL, or a guessed object key. A bucket you thought of as unlisted is not unlisted if its name is in this response. The public: true field tells you which of those names skip object-read checks entirely.
The scan stores the same fact and very little else. It sends one GET to /storage/v1/bucket with the publishable key. Only a 200 becomes the finding. Anything else, including a network failure, is silence. The evidence preview is the bucket names, truncated, not the object metadata and not the file bytes. A second finding, "Anon can access public storage buckets," is added only when a returned row has the public flag set. A third finding, "Anon can list objects in storage buckets," is the object listing, and it belongs to the other post.
Silence here is not a promise that objects are private. It means this call did not return the catalog. Check the object policies anyway if the bucket names are already in your client code.
How this differs from a public URL
The storage access control guide separates the operations. Uploads need an INSERT policy on storage.objects. A public bucket is already publicly accessible, so a read policy is not what serves .../object/public/<bucket>/<path>. A private bucket serves an object only when a SELECT policy allows it, or when you mint a signed URL.
The same guide points at a trap that sits next to the catalog. A broad SELECT on storage.objects lets callers list object names, not only fetch one path they already know. Supabase's helper storage.allow_any_operation() exists so a public-read policy can allow get without also allowing the list operation. The guide says that filter is critical, and that it is unnecessary for a public bucket because a public bucket is already world-readable. Listing names inside a bucket is the object finding. This post stops one step earlier: the caller learned the bucket names, and which of them are public, before any object call.
Both can be true together. A 200 from /storage/v1/bucket plus public: true on avatars means the catalog leaked and those files have public URLs. A 200 with public: false on invoices means the catalog leaked and the files might still be locked. Treat the second case as a map of a locked cabinet, not as proof the cabinet is open. Then read the object policies, which the other post walks through.
The schema page asks you to treat storage tables as read-only from SQL. Metadata lives in Postgres. The bytes live in the backing store. Deleting a metadata row does not delete the object, and the object can remain billed while becoming unreachable. Create, update, and delete buckets and objects through the Storage API. The policies you add are the exception: RLS on these tables is how access control is written.
What the publishable key is allowed to learn
The publishable key is supposed to be in the browser. The anon key post makes that case for tables. Storage is the same key and a different schema. anon can do what policies and grants allow, and nothing the secret key can do.
The access control guide is blunt about the secret key. If you send the service key in the Authorization header, Storage bypasses RLS. That key is for a server you trust. It does not belong in this curl, and it does not belong in the client. A listBuckets() that succeeds with the service key tells you nothing about what the browser can do. Always test with the publishable key.
Default policies vary by how the bucket was created. The access control guide says Storage does not allow uploads to a bucket with no RLS policies. That sentence is about uploads. It is not a promise that SELECT on storage.buckets is denied. The HTTP status is the check. Do not assume a new project is closed, and do not assume a project that has been through a dashboard wizard is closed either. Lovable and similar generators often create an avatars bucket and a permissive policy so the first upload works. The catalog call is how you see what that wizard left behind.
Owner and MIME columns are part of the same row. file_size_limit and allowed_mime_types are not secrets, but they tell a caller which bucket accepts which uploads. owner_id on the bucket is a user id when it is set. None of that requires listing objects. It arrives with the bucket details listBuckets() is documented to return.
Close the catalog, then the objects
Turn Public off on every bucket whose files are not meant to be fetched by URL. That is the dashboard control, Storage, the bucket, Public. It stops the public object URL. It does not, by itself, stop GET /storage/v1/bucket from returning the name.
Deny anon the select on storage.buckets if the browser does not need the catalog. A restrictive policy does that without deleting the buckets:
create policy "deny_anon_storage_buckets" on storage.buckets as restrictive for select to anon using (false);
Restrictive means this policy must pass in addition to any permissive policy. using (false) for anon fails the select. Authenticated policies you still want are untouched. Confirm the policy in the dashboard before you rely on it, and confirm with the same curl. A 401 or an empty refusal is what you want from the publishable key. A 200 with the same names means the policy did not attach, or a permissive path still wins because the role was not anon.
Signed-in users who should see only their own bucket still need a permissive policy that matches their id. Do not replace this deny with auth.uid() is not null on storage.objects. That expression admits every signed-in user, which is the login-only RLS bug moved onto files. The access control guide's per-user example compares (select auth.jwt()->>'sub') to owner_id, and it scopes bucket_id. Copy that shape only if those columns exist on the objects you already have.
Object listing and public reads are the next pass. Deny anon on storage.objects for select, or scope the select with storage.allow_any_operation() so a deliberate public read does not become a directory listing. The public bucket post has that half, including the difference between /object/public/... and storage.from(bucket).list().
After the policies are saved, rescan the verified domain. "Anon can list storage buckets" clears when /storage/v1/bucket no longer returns 200 to the publishable key. The public-bucket finding clears when no returned row is public. The object-list finding clears on its own request. The free scan runs those reads against the URL you submit. The fix prompt on the finding repeats the dashboard step and the deny policy, generated from that finding's copy, so a paste into Lovable, Cursor, or Claude starts from the bucket names the scan actually saw.