Supabase storage policies: keep users out of each other's files
Supabase storage policies are RLS policies on the storage.objects table. A policy that only checks bucket_id = 'documents' doesn't say whose file it is, so anyone it applies to can list and download every file in the bucket. A public bucket skips the download check completely.
QUICK CHECKLIST
- Private files live in private buckets
- Every select policy on a private bucket checks the owner or folder, not just bucket_id
- Uploads need a login and a folder check
- Update and delete check the owner
- Private files are served with signed URLs
- File size limit and MIME types set on the bucket
How access works in Supabase Storage
Every file is a row in storage.objects with a bucket_id, a path in name and an owner_id. Supabase's docs say Storage allows no uploads without RLS policies, and you open each operation with a policy: insert for uploads, select for downloads and listing, update and delete for changes. Upserts need select and update as well.
Public and private buckets differ only in downloads. In a public bucket, anyone with a file's URL can download it, with no policy involved. Uploads, deletes and moves still go through your policies in both, the same way RLS works on your tables.
Four patterns to look for
Select by bucket only. for select using (bucket_id = 'documents') without a to role applies to signed-out visitors. A plain select policy also lets people list the bucket's contents, and that includes public buckets.
Logged in means allowed. bucket_id = 'uploads' and auth.role() = 'authenticated' lets every account read, or with update and delete, change every file. One repo named such a policy "Applicants can view own presentation materials" and checked auth.role() = 'authenticated' or auth.role() = 'anon', which is everyone.
Private files in a public bucket. insert into storage.buckets (id, name, public) values ('resumes', 'resumes', true). Anyone who has a file's URL can download it, and a broad select policy lets them list the names.
Uploads without a login. for insert with check (bucket_id = 'avatars') with no role lets strangers fill your bucket. Supabase's Next.js tutorial uses this policy for avatars as a demo, and two of the repos we flagged had copied it.
What we found in public repos
We searched public GitHub code for migrations with both storage.buckets and select policies on storage.objects and read the SQL from 30 repos. Our check flagged 13. The most serious were a public evidence bucket in a safety-report app with a policy that lets anyone list it, a public resumes bucket next to a read policy for every bucket, and the "own presentation materials" policy above. Most other flags were uploads open to signed-out visitors. Three were read policies on picture buckets with other names (portfolio, records, stores) that were probably meant to be public.
We picked these files because they matched the search, so this is not a rate across all Supabase apps. We read every flagged policy by hand and didn't contact the repo owners.
How to write safe Supabase storage policies
Make private buckets private
update storage.buckets set public = false where id = 'documents';
Put each user's files in their own folder
Upload to
{user_id}/file.pdfand check the first folder:create policy "own folder: read" on storage.objects for select to authenticated using ( bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid()::text) );
Use the same check inwith checkfor insert, inusingfor delete, and in both for update.Or check owner_id
For files the user uploaded themselves, Supabase's ownership docs use
owner_id = (select auth.uid()::text). Files uploaded with the service key have no owner.Serve private files with signed URLs
createSignedUrl(path, 60)gives a link that expires.getPublicUrlonly works for public buckets and never expires.Limit uploads
Set a file size limit and allowed MIME types on the bucket, and keep uploads behind a login. Login-only deletes have the same problem as on tables, see Supabase delete policies.
create policy "read documents" on storage.objects for select using (bucket_id = 'documents');
create policy "own folder: read"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1]
= (select auth.uid()::text)
);Fix prompt you can paste
Review every Supabase Storage bucket and storage.objects policy in supabase/migrations. List buckets created with public = true that hold private files and make them private. For each select, insert, update and delete policy on storage.objects that only checks bucket_id or a login, add TO authenticated and a folder check (storage.foldername(name))[1] = (select auth.uid()::text). Switch the app from getPublicUrl to createSignedUrl for private files, set file size limits and allowed MIME types on each bucket, and write a test where a second user and a signed-out client try to list and download the first user's files.
How Leakcade checks this
Our storage check reads your SQL migrations. It flags select policies on storage.objects that only name a private bucket (for visitors or for any signed-in user), buckets set to public whose names suggest private files, and uploads open to signed-out visitors. Policies that check auth.uid(), owner_id or a folder are left alone, and login-only deletes and updates come from our policy check. Buckets made in the dashboard don't show up in migrations, and the report tells you to compare.
Buckets named avatars or images are skipped for reads. Pictures are usually public on purpose.
Further reading
- Supabase docs: Storage access control
- Supabase docs: Storage buckets (public and private)
- Supabase docs: Storage ownership
- Supabase docs: Storage helper functions
FAQ
Are Supabase storage buckets public by default?
No. Buckets are private by default. A public bucket lets anyone with a file's URL download it. Uploads and deletes still need policies.
How do I let users only access their own files in Supabase Storage?
Upload to a folder named after the user id and add policies with (storage.foldername(name))[1] = (select auth.uid()::text) for select, insert, update and delete.
Why can anyone list files in my public bucket?
A select policy on storage.objects allows listing. Public buckets don't need a select policy for downloads, so remove it or restrict it.
What's the difference between getPublicUrl and createSignedUrl?
getPublicUrl builds a permanent link that only works for public buckets. createSignedUrl creates a link to a private file that expires after the time you set.
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 repoLeakcade 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