Supabase Storage: public buckets, signed URLs, and policies that actually protect files
Table RLS gets all the attention, but uploaded files live in Storage and follow their own rules. A bucket flipped to public to make images load can quietly publish invoices and ID scans. This page shows what public really means and how to lock files to their owner.
Short answer: A public bucket serves every file to anyone who has its URL, with no policy check on reads. Use public buckets only for files that are meant to be public, like avatars. Keep everything else in a private bucket, write storage.objects policies that tie each file to auth.uid() through its folder path, and share files with short-lived signed URLs.
Table policies are covered in the Supabase RLS checklist, and the public key itself in is the Supabase anon key safe?. This page is only about files.
The problem: what "public" actually does
Supabase buckets are private by default. Marking one public, per Supabase's docs, bypasses access controls for retrieving and serving files: anyone who has the URL can fetch the file. Public URLs follow a fixed pattern:
https://YOUR-REF.supabase.co/storage/v1/object/public/BUCKET/path/to/file.pdf
That's fine for avatars and blog images. It's a leak when an AI-built app puts receipts, contracts or user uploads in the same bucket, because the URLs end up in your HTML, in API responses, in emails and in browser history. Anyone who sees one URL can share it, and anyone who guesses the naming scheme can try others.
Upload, delete, move and copy are still governed by policies on storage.objects even in a public bucket. Without any policy, Storage refuses uploads. The usual mistake is the opposite: a "make uploads work" fix like with check (true) that lets anyone with your public key write anywhere.
Check it yourself [manual]
- In the SQL Editor, list your buckets and whether they're public:
select id, public from storage.buckets; - For each public bucket, ask: is every file in here meant for anyone on the internet? If not, it should be private.
- List the storage policies and read each one:
Look forselect policyname, cmd, roles, qual, with_check from pg_policies where schemaname = 'storage' and tablename = 'objects';trueconditions, policies for theanonrole on private data, and select policies that don't check the owner. - Sign out, open a file URL from a private bucket, and confirm it fails.
Fix it
- Split public from private. Keep a public
avatarsbucket for files meant to be public, and move documents into a private bucket (for exampledocuments). Switching an existing bucket to private breaks any stored public URLs, so plan the change. - Store files under the owner's ID. Upload to
USER_ID/filename. The helperstorage.foldername(name)returns the folder parts of a path, so the first folder can be matched against the signed-in user. - Write owner-only policies on
storage.objects:
Add matching update and delete policies if users can replace or remove files. Overwriting withcreate policy "upload own files" on storage.objects for insert to authenticated with check ( bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.jwt()->>'sub') ); create policy "read own files" on storage.objects for select to authenticated using ( bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.jwt()->>'sub') );upsertneeds select and update as well as insert. - Serve private files with signed URLs. The link works for the number of seconds you choose:
const { data, error } = await supabase.storage .from('documents') .createSignedUrl(`${user.id}/invoice-1042.pdf`, 300); // 5 minutes
Move all private user uploads (documents, receipts, IDs) out of any public Supabase Storage bucket into a private bucket named "documents". Store each file under a folder named with the uploader's user ID. Add storage.objects policies for insert, select, update and delete that only allow authenticated users whose ID matches (storage.foldername(name))[1]. Replace getPublicUrl calls for these files with createSignedUrl using a short expiry. Leave the public avatars bucket as it is.
Make it stick
- Set bucket upload limits (max file size and allowed MIME types) when you create a bucket.
- Never use the service_role or secret key in the browser to "fix" a storage permission error. Secret keys bypass storage policies entirely.
- Re-run the bucket query whenever the AI adds an upload feature; new buckets are easy to create as public.
What the free scan checks here (and what it doesn't)
ShipWatch doesn't check storage buckets, whether they are public, or your storage.objects policies. That part is [manual]. It does flag a Supabase service_role JWT or sb_secret_ key, along with Stripe, OpenAI, Anthropic, AWS and Google keys, in your HTML and up to 25 linked scripts, and it notes your anon key and project URL as info. It also checks /.env, /.git/HEAD, HTTPS and four security headers.
FAQ
- Does a public Supabase bucket let anyone upload or delete files?
- Not by itself. Public only skips access control for downloading and serving files. Uploading, deleting, moving and copying still require policies on storage.objects. A broad insert policy, though, would let anyone with your public key upload.
- Are signed URLs safe to send to users?
- Yes, for a limited time. createSignedUrl takes an expiry in seconds, and anyone holding the link can download the file until then. Keep expiries short, and generate them only after checking the requester is allowed to see the file.
- Does the free scan check my storage buckets?
- No. ShipWatch doesn't check storage buckets or their policies. It does flag a leaked service_role JWT or sb_secret_ key, which would bypass storage policies entirely, plus other secret keys, /.env, /.git/HEAD, HTTPS and four security headers.