Supabase database
Supabase storage bucket policies: public, private and per-user folders
Supabase Storage decides who can download, upload, replace and delete files with row level security policies on the storage.objects table, plus one switch per bucket: a public bucket serves every file to anyone with its URL. For personal files, use a private bucket, put each user’s files in a folder named after their user id, and write policies that only allow that folder.
TL;DR
- Public bucket: anyone with a file’s URL can download it, no sign-in. Fine for avatars, wrong for ID scans and invoices.
- Private bucket: every operation goes through your
storage.objectspolicies. A policy that checks onlybucket_idcovers every file, for everyone it applies to. - Per-user folders: upload to
<user id>/fileand compare(storage.foldername(name))[1]withauth.uid(). - Show private files with short-lived signed URLs, not public URLs.
How Storage access works
Each file in Supabase Storage has a row in storage.objects, with its bucket_id and its path in name (for example 3f2c…/passport.jpg). Buckets are rows in storage.buckets. When your app calls supabase.storage.from('documents').upload(…) or .download(…), the Storage API checks your row level security policies on storage.objects for the signed-in user, the same way the database API checks policies on your tables:
| Operation | Policy needed on storage.objects |
|---|---|
| Download, list, create a signed URL | select |
| Upload | insert |
Replace (upload with upsert: true) | select, insert and update |
| Delete | delete |
With no policies, nothing is allowed through the API. That’s safe, and it is also why people reach for a broad policy the first time an upload fails.
Public vs private buckets
A bucket’s public flag changes one thing: files can be downloaded through their public URL by anyone, with no sign-in and no policy check. Uploading, replacing, deleting and listing still go through your policies.
- Public is right for files meant for everyone: avatars, product images, logos, blog images. Use
getPublicUrl(). - Private is right for anything personal: ID documents, CVs, invoices, contracts, medical letters, private photos. Use
createSignedUrl(), which issues a link that expires.
Don’t mix the two in one bucket. Make a public bucket for public assets and a private one for everything else.
select id, name, public from storage.buckets order by public desc;The broad policy that leaks
This is the most common shape in AI-built apps. The bucket is private, so it looks safe, but each policy names only the bucket and has no to clause:
create policy "Allow uploads" on storage.objects
for insert with check (bucket_id = 'documents');
create policy "Allow reads" on storage.objects
for select using (bucket_id = 'documents');
create policy "Allow deletes" on storage.objects
for delete using (bucket_id = 'documents');Anyone with your public key, signed in or not, can list every file in the bucket, download them, upload their own files into it and delete other people’s. List your storage policies to see whether you have this:
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'storage' and tablename = 'objects'
order by (coalesce(qual, '') || coalesce(with_check, '')) ilike '%auth.uid()%', cmd;Policies at the top of the result never mention the user. If they cover a bucket with personal files, they need replacing.
Per-user folders: the policies
Give each user a folder named after their user id, and allow each operation only inside it. storage.foldername(name) splits a path into its folders, so (storage.foldername(name))[1] is the first folder.
insert into storage.buckets (id, name, public)
values ('documents', 'documents', false)
on conflict (id) do update set public = false;
create policy "Users read own documents" on storage.objects
for select to authenticated
using (bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "Users upload own documents" on storage.objects
for insert to authenticated
with check (bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "Users replace own documents" 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 "Users delete own documents" on storage.objects
for delete to authenticated
using (bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text);Drop the old broad policies in the same migration (drop policy "Allow reads" on storage.objects; and so on).
The app code that goes with it
import { supabase } from '@/integrations/supabase/client';
export async function uploadDocument(file: File) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Sign in first');
// The first folder must be the user's id, or the policy refuses it
const path = `${user.id}/${crypto.randomUUID()}-${file.name}`;
const { error } = await supabase.storage.from('documents').upload(path, file);
if (error) throw error;
return path;
}
export async function documentLink(path: string) {
// A link that works for 60 seconds, instead of a permanent public URL
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(path, 60);
if (error) throw error;
return data.signedUrl;
}If the app used getPublicUrl() for these files before, replace each call. A public URL for a file in a private bucket doesn’t work, which is what you want.
Test it as a visitor and a stranger
File rows can be tested like any table, inside a transaction that changes nothing:
-- A logged-out visitor should see no file names
begin;
set local role anon;
select count(*) from storage.objects where bucket_id = 'documents';
rollback;
-- A signed-in stranger should see none either
begin;
set local role authenticated;
select set_config('request.jwt.claims',
json_build_object('sub', gen_random_uuid(), 'role', 'authenticated')::text, true);
select count(*) from storage.objects where bucket_id = 'documents';
rollback;Then, as a real test user in the app, upload a file, open it through the signed link, and confirm a second test user can’t download it by path.
Upload limits for public forms
If a form must accept files from logged-out visitors (a job application, say), keep that bucket private, allow insert to anon only for that bucket, never allow select to anon, and set the bucket’s file size limit and allowed file types in its settings so it can’t become free hosting for anything at all.
Sources
Related findings
The checks behind this guide, each with example code, a self-check and the fix.
Free check
Check your app for this, free.
Paste your public repo. PatchLabs rebuilds your database from its migrations, tests it as a visitor, a stranger and the owner, reads the code for keys and risky patterns, and hands you the fix.
Written by Manraj Singh Dhillon. Last checked 10 Nov 2026. Code samples are synthetic and run against your own project; nothing here names a real app. Spotted a mistake? Tell us.