Make the bucket private
Switch the bucket to private in the dashboard or with SQL. After that, every download goes through a policy or a signed URL.
update storage.buckets set public = false where id = 'user-files';
Put the owner's id first in every object path
Use one path convention everywhere: the user's id, then the file. Build the path from the signed-in session on the client, not from a form field.
const { data: { user } } = await supabase.auth.getUser()
const path = `${user.id}/${crypto.randomUUID()}-${file.name}`
const { error } = await supabase.storage.from('user-files').upload(path, file)
Write insert, select and delete policies that compare the folder to the user
Each operation needs its own policy on storage.objects, scoped to the bucket and to the authenticated role. If you upload with upsert: true, also add an UPDATE policy with the same check.
create policy "owner uploads" on storage.objects for insert to authenticated
with check (bucket_id = 'user-files' and (storage.foldername(name))[1] = (select auth.uid()::text));
create policy "owner reads" on storage.objects for select to authenticated
using (bucket_id = 'user-files' and (storage.foldername(name))[1] = (select auth.uid()::text));
create policy "owner deletes" on storage.objects for delete to authenticated
using (bucket_id = 'user-files' and (storage.foldername(name))[1] = (select auth.uid()::text));
Serve files through short-lived signed URLs
Create a signed URL when the file is needed, and don't store it. The expiry is set in seconds. Keep it short, because individual signed URLs can't be revoked; to invalidate one early, delete, move, or rename the underlying object.
const { data, error } = await supabase.storage.from('user-files').createSignedUrl(path, 60)
Keep the service role key on the server
The service role key bypasses every storage policy. Objects it creates have no owner set. If an upload path only works with this key, it is hiding a missing policy.
On Firebase, match the path segment to request.auth.uid
Cloud Storage Security Rules use the same approach: capture the user id from the path and compare it with the signed-in user. For private files, use getBlob() or getBytes() instead of handing out download URLs.
match /user/{userId}/{fileName} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
Test as two users and as an anonymous visitor
A test with one account can't show that owner-only rules work. Run the checks below before every release that touches storage.