LEAKCADE

Supabase storage policies: keep users out of each other's files

Updated 4 min read

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.

How a bucket-only policy hands out every fileA visitor with the anon key lists the documents bucket, the select policy only checks bucket_id, so every user's files are listed and can be downloaded.VISITORAnon key fromyour frontendLISTstorage.from('documents').list()POLICYbucket_id ='documents'passesRESULTEvery user'sfilesVISITORAnon key from your frontendLISTstorage.from('documents').list()POLICYbucket_id = 'documents' passesRESULTEvery user's files

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

  1. Make private buckets private

    update storage.buckets set public = false
    where id = 'documents';
  2. Put each user's files in their own folder

    Upload to {user_id}/file.pdf and 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 in with check for insert, in using for delete, and in both for update.

  3. 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.

  4. Serve private files with signed URLs

    createSignedUrl(path, 60) gives a link that expires. getPublicUrl only works for public buckets and never expires.
  5. 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.

BEFOREAnyone reads every file
create policy "read documents"
on storage.objects for select
using (bucket_id = 'documents');
AFTERUsers read their own 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)
);

Fix prompt you can paste

FIX PROMPT
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

  1. Supabase docs: Storage access control
  2. Supabase docs: Storage buckets (public and private)
  3. Supabase docs: Storage ownership
  4. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.