Supabase Storage is where many AI-built apps keep avatars, uploaded documents, invoices and generated images. It's easy to set up, and easy to set up in a way that lets anyone download everyone's files.
This guide covers the decisions that matter: public or private buckets, the policies that control access, how to share files safely, and how to limit uploads.
How Storage access works
Supabase Storage keeps file metadata in a Postgres table, storage.objects. Access is controlled by RLS policies on that table, just like your own tables. Each bucket also has a public flag:
- Public bucket: anyone with a file's URL can download it, without any auth. Policies still control uploads, updates and deletes.
- Private bucket: every download needs to pass your RLS policies, either through an authenticated request or a signed URL.
So the first question for each bucket is simple: should a stranger with the link be able to see this file?
☐ 1. Audit which buckets are public
Run this in the SQL editor:
select id, name, public, file_size_limit, allowed_mime_types
from storage.buckets
order by public desc, name;
Good candidates for public: marketing images, public product photos, blog images, user avatars if you're fine with them being visible.
Should be private: anything users upload that isn't meant to be public. ID documents, invoices, contracts, chat attachments, exports, generated reports, and anything with personal data.
AI tools often create a bucket as public because it makes getPublicUrl() work right away. If a bucket holds private files, switch it to private in Storage > bucket settings, then update your code to use signed URLs (below).
Note that "the URL is long and random" is not access control. URLs get shared, logged and indexed.
☐ 2. Organize files by user ID
The simplest pattern for per-user files is to put each user's files in a folder named after their user ID:
private-files/
6f1c...a2/invoice-2026-10.pdf
6f1c...a2/avatar.png
91de...7b/contract.pdf
Upload with that path from the client:
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Not signed in");
const path = `${user.id}/${crypto.randomUUID()}-${file.name}`;
const { error } = await supabase.storage
.from("private-files")
.upload(path, file, { contentType: file.type, upsert: false });
Using a random prefix also avoids one upload overwriting another with the same name.
☐ 3. Write per-user policies on storage.objects
With the folder structure above, these policies let each user manage only their own folder:
create policy "private-files: read own"
on storage.objects for select to authenticated
using (
bucket_id = 'private-files'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "private-files: upload own"
on storage.objects for insert to authenticated
with check (
bucket_id = 'private-files'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "private-files: update own"
on storage.objects for update to authenticated
using (
bucket_id = 'private-files'
and (storage.foldername(name))[1] = (select auth.uid())::text
)
with check (
bucket_id = 'private-files'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "private-files: delete own"
on storage.objects for delete to authenticated
using (
bucket_id = 'private-files'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
storage.foldername(name) splits the path into folders, and [1] is the first one (Postgres arrays start at 1).
Always include bucket_id = '...' in each policy. A policy without it applies to every bucket in the project.
Note: upserting a file (upsert: true) needs select and update permission as well as insert.
☐ 4. Watch for overly broad policies
Check your existing Storage policies for patterns like these:
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'storage' and tablename = 'objects';
Red flags:
qualorwith_checkthat is justtrue- Policies for
publicoranonthat allow insert, update or delete - Policies that check only
bucket_idwith no user condition, on a bucket with personal files
A policy like "authenticated users can upload to avatars" without a folder check lets any user overwrite or delete anyone else's avatar.
☐ 5. Use signed URLs for private files
To show a private file in the browser, create a short-lived signed URL:
const { data, error } = await supabase.storage
.from("private-files")
.createSignedUrl(path, 60); // valid for 60 seconds
if (data) window.open(data.signedUrl);
When called with the user's session, this only succeeds if your select policy allows them to read the file. Keep expiry times short. A signed URL works for anyone who has it until it expires.
If you generate signed URLs on the server with the service role key, RLS is bypassed, so your code must check that the requesting user is allowed to see that file first. See securing Next.js API routes with auth.
☐ 6. Limit file size and type
Without limits, users can upload huge files or files your app doesn't expect. Set limits on the bucket itself, which Supabase enforces server-side:
update storage.buckets
set file_size_limit = 5242880, -- 5 MB
allowed_mime_types = array['image/png', 'image/jpeg', 'image/webp', 'application/pdf']
where id = 'private-files';
You can also set these in the dashboard under the bucket's settings. Client-side checks are nice for user experience but don't count as protection, since anyone can bypass your frontend.
Be careful about allowing text/html or image/svg+xml in a public bucket. Those can contain scripts, and serving user-uploaded HTML or SVG from a public URL can be abused for phishing or cross-site scripting.
☐ 7. Keep the service role key server-side
The service role key bypasses every Storage policy. It should only be used in Edge Functions or server code. If it's in your frontend or a VITE_/NEXT_PUBLIC_ variable, anyone can read, replace or delete every file. Rotate it and move that logic server-side. See how to rotate leaked Supabase and OpenAI keys.
☐ 8. Clean up when users delete their account
If you delete a user's rows but not their files, those files stay in Storage. Make account deletion (usually an Edge Function or server route) also remove the user's folder, using the Storage API's list and remove methods. That keeps you from holding personal data longer than you need to.
☐ 9. Test as a different user
Sign in as user A and upload a file. Then sign in as user B and try to:
- Download A's file with
download(path) - Create a signed URL for A's file
- Upload a file into A's folder
- Delete A's file
Each should fail. Then log out entirely and try the same. If the bucket is private, the public URL for A's file should not work.
Related checks
Storage policies sit alongside your table policies, so run through the Supabase RLS checklist too. If you built with Bolt or Lovable, the Bolt.new security checklist and is my Lovable app secure? cover the rest of a typical stack.
Run a scan
PreflightPR reviews Supabase projects in your repo for tables without RLS, leaked keys and other common launch issues in AI-built apps. Run a free scan on a public repo to get a launch score and a prioritized list of fixes. It's an automated best-effort review, so still test your buckets as a second user before launch.