hackymacky

Notes

← Blog

Lock down Supabase Storage public buckets and listable objects

8 Oct 2026 · 1157 words

A public bucket skips read checks. A private bucket with a wide SELECT policy still lists every object. How to tell which one you have, and the SQL that closes it.

Supabase Storage is Postgres with a file API in front of it. Buckets live in storage.buckets. Objects live in storage.objects. The same grant + RLS rules that decide whether anon can read public.profiles decide whether anon can list your uploads.

Two switches get confused, especially in vibe-coded apps that needed an avatar upload on day one.

Public bucket. Official docs: when a bucket is public, retrieving and serving files bypasses access control. Anyone who has the asset URL can fetch the file. Uploads, deletes, moves, and copies still go through RLS.

Listable bucket. A SELECT policy on storage.objects that matches anon lets the list API return object names and metadata. That is a directory listing even when you never marked the bucket public.

You can have one, the other, or both. A private bucket with a using (true) select policy is a common "I followed a gist" outcome: files are not on a CDN-style public URL, but anyone with the publishable key can list every key in the bucket.

How public URLs and list calls differ

A public object is fetched like this:

code

https://YOUR_PROJECT.supabase.co/storage/v1/object/public/avatars/user/face.png

No Authorization header. If that URL returns the file, the bucket is public or you have put a public-read policy in front of a path you did not mean to share.

A private object is fetched with a user JWT that passes RLS, or with a signed URL from createSignedUrl. Signed URLs expire. That is the path for invoices, ID scans, and anything you would not paste in a group chat.

Listing is a different endpoint. The JS client storage.from('avatars').list() talks to the Storage API with your publishable key. Whether names come back is a SELECT on storage.objects.

Ask the API what anon can see

Use the same publishable / anon key your Next.js client already has. Do not use the secret key for this — service_role bypasses Storage RLS and will always succeed.

bash

SUPABASE_URL="https://YOUR_PROJECT.supabase.co"
ANON_KEY="eyJ…or-sb_publishable"

curl -sS -D - \
  "$SUPABASE_URL/storage/v1/bucket" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY"

A JSON array of buckets, each with "public": true|false, means anon can list bucket metadata. That is already more than most apps need. A 400/401/403 is the result you want if Storage is only for signed-in users.

Then pick a bucket name you saw — or one you know from the Dashboard — and list objects:

bash

curl -sS -D - \
  "$SUPABASE_URL/storage/v1/object/list/avatars" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ANON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"","limit":20}'

Object names, even without downloading bytes, are a map: invoices/2026/acme.pdf, id-scans/user-uuid.jpg. Treat listing as sensitive.

For a bucket you believe is public, fetch one object by path without auth:

bash

curl -sS -D - \
  -o /tmp/storage-probe.bin \
  "$SUPABASE_URL/storage/v1/object/public/avatars/SOME_PATH"

200 plus bytes: public read is on. 400 / 404: the path is wrong or the bucket is private.

None of these calls should use a production secret key. If you only have the secret key locally, you are testing the wrong role.

Turn Public off, then write policies

Dashboard → Storage → open the bucket → disable Public unless the files are supposed to be world-readable (marketing images, a blog CDN). Public is a cache and URL convenience, not a substitute for "users can read their own uploads."

Default Storage is deny-by-default for uploads: without an INSERT policy on storage.objects, uploads fail. Reads on a private bucket are also deny-by-default. The policies you add should be narrower than the gist you pasted at 1am.

A locked-down starting point for a private user-uploads bucket:

sql

-- Dashboard → Storage → avatars → Public: off

create policy "avatars_insert_own"
on storage.objects
for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy "avatars_select_own"
on storage.objects
for select
to authenticated
using (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy "avatars_update_own"
on storage.objects
for update
to authenticated
using (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
)
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy "avatars_delete_own"
on storage.objects
for delete
to authenticated
using (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

Upload to avatars/<user-id>/…. The first folder name is the uid. anon has no policy, so list and download fail for a signed-out publishable key.

If you must serve a file to someone who is not signed in, create a signed URL on the server:

ts

import "server-only";
import { createAdminClient } from "@/lib/supabase/admin";

export async function signedAvatarUrl(path: string) {
  const supabase = createAdminClient();
  const { data, error } = await supabase.storage
    .from("avatars")
    .createSignedUrl(path, 60);
  if (error || !data) {
    throw new Error(error?.message ?? "Could not sign URL");
  }
  return data.signedUrl;
}

Keep that function in a Route Handler. The secret / service_role client stays off the browser graph — see the bundle post.

Public reads without a public listing

Sometimes you want GET of a specific object with the publishable key, but not list. Storage helper functions exist for that. From the access-control guide: a SELECT policy without an operation filter lets users list the bucket. storage.allow_any_operation(...) / storage.allow_only_operation(...) restrict the policy to get-style operations.

sql

create policy "avatars_public_get"
on storage.objects
for select
to anon, authenticated
using (
  bucket_id = 'avatars'
  and storage.allow_any_operation(
    array[
      'object.get_authenticated_info',
      'object.get_authenticated'
    ]
  )
);

The docs note that this is unnecessary on a bucket that is already public — public already serves the file. Use the helper on a private bucket when you want authenticated-info / authenticated get without directory listing.

If you do not need public get at all, do not add a select policy for anon. Re-run the list curl. You want an error, not [] with a 200 that still includes names on a different prefix.

Deny anon on the catalog tables when Storage is private

If this project should not expose bucket names to the internet, deny anon on both Storage catalogs after you turn Public off.

sql

create policy "deny_anon_storage_objects"
on storage.objects
as restrictive
for all
to anon
using (false)
with check (false);

create policy "deny_anon_storage_buckets"
on storage.buckets
as restrictive
for select
to anon
using (false);

Policies are permissive by default and combine with OR. A permissive using (false) next to using (true) does not win. as restrictive ANDs, so later permissive policies cannot reopen anon listing or API access. Postgres still needs at least one permissive policy to grant a row; a lone restrictive policy is a deny.

This guard does not make a public bucket private. Public-bucket downloads go through /storage/v1/object/public/… and bypass RLS. Turn Public off if the files are not a CDN. The restrictive policy only blocks anon listing and the authenticated Storage API.

service_role / the secret key ignores these policies. That is expected. It is also why a secret key in the client is a Storage leak as well as a database leak.

What usually goes wrong in a scaffold

An avatars bucket marked public so <img src={publicUrl}> works in the first PR. Fine for cartoon avatars. Not fine if the same bucket later stores KYC uploads because "we already had Storage."

A policy copied from a gist: on storage.objects for select using (true). That lists every bucket the role can see, not just avatars.

Folder names that are display names or emails instead of auth.uid(). Guessable prefixes plus a list policy is a user directory.

Anonymous sign-in enabled, plus a policy that only checks auth.uid(). Throwaway sessions get a uid. They pass. See Auth posture.

After you change policies, re-run the same three curls

  1. GET /storage/v1/bucket with the publishable key.
  2. POST /storage/v1/object/list/<bucket> with an empty prefix.
  3. GET /storage/v1/object/public/<bucket>/<path> without auth.

Then upload one file as a signed-in user and confirm that user can download it and a second account cannot.

hackymacky's preview scan already lists buckets, reads public flags, and probes object listing with the anon key, along with the JS secret check and REST / OpenAPI / RPC / write probes. What you see without an account is the score, severity counts, and three redacted titles. Claiming the domain unlocks the full finding list with fix snippets, Auth posture, daily rescans, history and diff, alerts, triage, the badge, and a report. Daily rescans pick up a bucket someone flipped back to public.

The curls above are still the check I keep in the README.