Notes / Supabase storage
Securing file uploads in Supabase Storage
Uploads are the easiest place to leave a door open: a public bucket, a policy that lets anyone write, or a file path the user gets to choose.
File uploads have two separate security questions, and it is easy to answer one and forget the other. Who can put files in is decided by row-level security policies on storage.objects. Who can read them is decided mostly by whether the bucket is public.
1. Decide public or private, per bucket
A public bucket means anyone with the URL can read the file, with no policy check and no sign-in. That is right for site logos and product images. It is wrong for anything belonging to a user: profile photos, documents, receipts, anything uploaded in a support form.
See what you have, in the SQL Editor:
select id, public, file_size_limit, allowed_mime_types from storage.buckets;If a bucket holding user files says public = true, that is the first thing to change.
2. Write upload policies around a folder per user
The pattern that holds up is one folder per user, named after their user ID, with policies that check the first folder in the path. In the SQL Editor:
create policy "users upload to their own folder"
on storage.objects for insert to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "users read their own files"
on storage.objects for select to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "users replace their own files"
on storage.objects for update to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
)
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "users delete their own files"
on storage.objects for delete to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);I tested this policy against the obvious ways around it. Uploading into another user's folder, uploading to the bucket root with no folder, uploading to a different bucket, and uploading while signed out were all refused.
One case did get through, and it is worth knowing about. A file named your-id/../someone-else/photo.png passes the policy, because the first folder in the path is still your own ID. The file does not actually land in the other user's folder, since these names are plain keys rather than filesystem paths, but it does create an odd object that path-splitting code in your app may display in strange places. The fix is the next step.
3. Generate the path, don't accept one
If the client chooses the whole path, you are relying on your policy to catch every strange thing a user might type. Build the path in code instead, from the session and a random name:
const ext = (file.name.split('.').pop() ?? 'bin').toLowerCase().replace(/[^a-z0-9]/g, '');
const path = `${user.id}/${crypto.randomUUID()}.${ext}`;
const { error } = await supabase.storage
.from('avatars')
.upload(path, file, { contentType: file.type, upsert: false });This puts the file in the right folder every time, strips anything unusual out of the name, and stops one user overwriting another's file by guessing its name. Store the returned path in your database next to the row it belongs to.
4. Set size and type limits on the bucket, not in the browser
Checking the file size and type in your upload form is good for the user and useless for security, because anyone can call the API directly. Set the real limits on the bucket, where the server enforces them:
update storage.buckets
set file_size_limit = 2097152, -- 2 MB
allowed_mime_types = array['image/png', 'image/jpeg', 'image/webp']
where id = 'avatars';Without a size limit, one visitor with a script can fill your storage quota and your bill.
5. Serve private files with signed URLs
For a private bucket, ask Supabase for a temporary link when the user needs the file:
const { data, error } = await supabase.storage
.from('avatars')
.createSignedUrl(path, 60); // valid for 60 secondsKeep the expiry short, generate the link at the moment it is needed, and don't paste signed URLs into anything permanent such as an email template.
The upsert gotcha
If you upload with upsert: true, the operation may update an existing object rather than insert a new one, which means it needs your select and update policies as well as the insert one. If replacing a file fails with "new row violates row-level security policy" while a fresh upload works, a missing update policy is usually why.
Check what you have now
The free self-check includes storage: it flags any policy that lets signed-out visitors upload, change or delete files, which is the most common way an upload feature goes wrong.