Supabase storage bucket security

By the RowLocked team · Updated

Supabase Storage is where most AI-built apps keep avatars, uploaded documents, receipts, and generated images. It is also a common place for private files to end up public, because the quickest way to make an upload "work" is to tick the Public bucket box.

This guide explains exactly what public and private buckets do, how to tell whether your files are exposed, and how to set up policies so users can only reach their own files.

How Supabase Storage access works

Storage keeps file metadata in a Postgres table, storage.objects, and Row Level Security on that table controls who can upload, download, list, update, and delete. Each bucket also has an access model:

  • Private bucket (the default): every operation, including downloads, goes through RLS policies on storage.objects. Files are fetched with the user's token or via a time-limited signed URL.
  • Public bucket: anyone who has a file's URL can download it, with no policy check. Supabase's docs describe this as bypassing access control for retrieving and serving files. Uploads, deletes, moves, and copies are still controlled by policies.

A public file URL looks like this, and it never expires:

https://YOUR-PROJECT.supabase.co/storage/v1/object/public/BUCKET/path/to/file.pdf

Public buckets are faster because they cache well, and they are the right choice for content that is meant to be public anyway: marketing images, blog media, product photos, and often profile pictures.

How private files leak

In apps we look at, files usually become exposed in one of these ways:

  1. Sensitive files in a public bucket. ID documents, invoices, medical forms, or private chat attachments uploaded into a bucket that was made public to get image previews working.
  2. Guessable paths. Public URLs are only as secret as their paths. A path like uploads/123/passport.jpg with sequential IDs can be walked by anyone.
  3. A listing policy that is too broad. A policy such as for select using (bucket_id = 'documents') on a private bucket lets anyone with the public key list and download every file in it.
  4. Long-lived signed URLs stored in a readable table. A signed URL valid for a year, saved in a table that other users can read, is effectively public.
  5. Service key in the browser. The service_role or secret key bypasses all Storage policies. See Supabase service_role key exposed.

Step 1: Audit your buckets

List buckets and whether they are public, then list every Storage policy, in the SQL Editor:

select id, name, public, file_size_limit, allowed_mime_types
from storage.buckets
order by name;

select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'storage' and tablename = 'objects'
order by policyname;

For each public bucket, ask: would I be comfortable if every file in here was indexed by a search engine? If not, it should be private.

For each policy, read the qual and with_check. Look for policies that only check bucket_id without tying the file to the user.

Step 2: Make sensitive buckets private

You can switch a bucket to private in the dashboard (Storage, bucket settings) or with SQL:

update storage.buckets set public = false where id = 'documents';

Existing public URLs to that bucket stop working immediately, so update your app to use signed URLs or authenticated downloads first (Step 4), then flip the switch.

While you are here, set limits so the bucket only accepts what you expect:

update storage.buckets
set file_size_limit = 10485760,                     -- 10 MB
    allowed_mime_types = array['application/pdf', 'image/png', 'image/jpeg']
where id = 'documents';

Step 3: Add per-user policies on storage.objects

The standard pattern is to store each user's files under a folder named with their user ID, for example documents/USER-ID/receipt.pdf, and check that folder in the policies. storage.foldername(name) splits the path into an array of folder names.

-- Users can upload only into their own folder
create policy "documents: upload own"
on storage.objects for insert to authenticated
with check (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

-- Users can read only their own files
create policy "documents: read own"
on storage.objects for select to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

-- Users can replace and delete only their own files
create policy "documents: update own"
on storage.objects for update to authenticated
using (bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid()::text))
with check (bucket_id = 'documents' and (storage.foldername(name))[1] = (select auth.uid()::text));

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

Storage also records an owner_id for each object (the user who uploaded it), so an alternative is using ((select auth.uid()::text) = owner_id). The folder approach has the advantage that the upload policy controls where files can be written.

Note that upserting (overwriting) a file requires select and update permissions in addition to insert.

In the client, upload with the user's ID as the first folder:

const { data: { user } } = await supabase.auth.getUser();
const path = `${user!.id}/${crypto.randomUUID()}.pdf`;
const { error } = await supabase.storage.from("documents").upload(path, file, {
  contentType: "application/pdf",
  upsert: false,
});

Using a random file name instead of the original also avoids leaking names like john-smith-passport.jpg and prevents collisions.

Step 4: Serve private files with signed URLs

For a private bucket, create a short-lived signed URL when the user needs to view a file. This requires a select policy that allows the user to read that object, which the policy above provides.

const { data, error } = await supabase.storage
  .from("documents")
  .createSignedUrl(path, 60); // valid for 60 seconds

if (data) window.open(data.signedUrl, "_blank");

Keep expiry times short and generate URLs on demand rather than saving them in the database. Supabase's docs note that signed URLs remain valid until they expire, even if you rotate your Auth keys, so a long expiry cannot easily be revoked.

If you need a user to share a file with another user, create the signed URL server-side in an Edge Function after checking that the requester is allowed to share it.

Step 5: Public buckets that are meant to be public

For avatars or product images, a public bucket is fine as long as writes are controlled:

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

You do not need a select policy for downloads from a public bucket; files are already readable by URL. Avoid adding a broad select policy on a public bucket, because it can let anyone list all files through the API even though they would otherwise need to know each path.

Also set allowed_mime_types to images only. Without it, someone could upload an HTML file or a large video into your public bucket and use your domain's storage to host it.

A prompt for your AI tool

Review how this app uses Supabase Storage. For each bucket, tell me whether it is public or private and what kind of files it holds. Make any bucket holding user documents or private uploads private. Store files under a folder named with the user's id, add storage.objects policies so users can only insert, select, update and delete within their own folder, and serve private files with createSignedUrl using a short expiry. Set file size limits and allowed MIME types on every bucket. Never use the service_role key in the browser.

Check it from the outside

After making changes, copy the URL of a private file and open it in a private browsing window. A private bucket should refuse it. RowLocked's free scan checks your live app for exposed Supabase configuration and public data, and the weekly re-check catches regressions after future edits.

Frequently asked questions

Are files in a public Supabase bucket indexed by Google?

They can be if a public URL is linked anywhere a crawler can reach. Do not rely on obscure paths to keep files private; use a private bucket.

Do Storage policies apply to the service_role key?

No. The service_role and secret keys bypass Storage policies entirely, which is why they must stay in server code.

Why do my uploads fail with "new row violates row-level security policy"?

There is no insert policy that matches the upload. Check the bucket ID and that the first folder in the path equals the signed-in user's ID, exactly as the policy expects.

Should profile pictures be public?

Usually yes, if they are shown to other users anyway. Keep the bucket public for reads, restrict uploads to the user's own folder, and limit file types and sizes.

Related guides

All guides

Check my app now