Supabase Storage bucket policies: public vs private
A public bucket means anyone with an object's URL can download it, no login needed. A private bucket (the default) needs the user's JWT or a time-limited createSignedUrl() link, checked against RLS policies on storage.objects. Public buckets still enforce those policies for uploads, deletes, moves, copies and listing — only direct downloads by URL are exempt.
Vibe-coded apps that store user uploads — avatars, PDFs, exports — in Supabase Storage usually get one of two things wrong: a bucket marked public when some of its files should not be, or a bucket marked private where the code still calls getPublicUrl() and wonders why downloads fail. The SQL policy patterns below follow the same shape as the multi-tenant auth.uid() guide and are written against the official Supabase examples cited in Sources; they have not yet been run against PostgreSQL or a real Supabase project (⚠️ not tested — see Sources for where each pattern comes from).
What "public" actually changes
Supabase's Storage docs describe two access models (Storage buckets fundamentals):
- Private (default): every operation, including download, is subject to the RLS policies on
storage.objects. A client needs either the signed-in user's JWT or a signed URL. - Public: "anyone who possesses the asset URL can readily access the file" — downloading no longer checks policies. Supabase's own wording is that the public flag "bypasses access controls for retrieving and serving files"; it does not say the same for other operations.
- Everything else stays policy-gated. The same page says access control "is still enforced for other types of operations including uploading, deleting, moving, and copying" — public or not. Supabase's quickstart tutorial, for a bucket it names "public", still writes an explicit policy (
"Public Access" on storage.objects for select using ( bucket_id = 'public' )) rather than relying on the bucket flag alone (Storage quickstart).
In practice: a public bucket is for files you are fine with anyone finding if they get the URL (avatars, product images). A private bucket is for anything else, and "private" only works if you also write the policies below — by default, with no policy at all, storage.objects allows nothing.
1. Create the bucket with an explicit public flag
-- SQL: insert the bucket row directly
insert into storage.buckets (id, name, public)
values ('user-uploads', 'user-uploads', false);
// JS: supabase-js, same effect
const { data, error } = await supabase.storage.createBucket('user-uploads', {
public: false,
});
Supabase's bucket-creation docs confirm the default is public: false (Creating buckets). Set it explicitly anyway — a bucket that should hold private documents should never depend on a default a future migration could change.
2. Enable RLS and write per-operation policies
storage.objects ships with row level security already on. With zero policies, every role — including the bucket owner's own app — gets nothing. Supabase's rule: "Storage does not allow any uploads to buckets without RLS policies" (Storage access control). One policy per operation, same as table RLS:
create policy "authenticated users upload to user-uploads" on storage.objects for insert to authenticated with check ( bucket_id = 'user-uploads' ); create policy "authenticated users read user-uploads" on storage.objects for select to authenticated using ( bucket_id = 'user-uploads' );
This is the minimum to stop anon from touching the bucket at all. It does not yet stop one signed-in user from reading another's files — that is step 3.
3. Scope policies to each user's own folder
Supabase's access-control examples use storage.foldername(), a helper that returns the path split into an array, to check the first folder segment against the caller (Storage access control; Storage helper functions):
create policy "users upload to their own folder" on storage.objects for insert to authenticated with check ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = (select auth.uid()::text) ); create policy "users read their own folder" on storage.objects for select to authenticated using ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = (select auth.uid()::text) ); create policy "users update their own folder" on storage.objects for update to authenticated using ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = (select auth.uid()::text) ) with check ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = (select auth.uid()::text) ); create policy "users delete from their own folder" on storage.objects for delete to authenticated using ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = (select auth.uid()::text) );
This assumes uploads go to a path like user-uploads/<user-id>/avatar.png — the app, not the client, should build that path so a user cannot write (storage.foldername(name))[1] as someone else's ID. Supabase's own examples write the equivalent check as (storage.foldername(name))[1] = (select auth.jwt()->>'sub') (the JWT's sub claim is the user's ID as text) or, for a flat layout with no folders, (select auth.jwt()->>'sub') = owner_id against the owner_id column Storage sets on upload. Either form is the same check; auth.uid()::text reads more consistently next to table policies.
to authenticatedkeeps the policy from evaluating foranonat all, same reasoning as table RLS.with checkonupdatestops a user from renaming a file into someone else's folder;usingalone only gates which existing rows they can touch.- Other helper functions for narrower policies:
storage.filename(name)(just the file name),storage.extension(name)(the extension, for a content-type allowlist), andstorage.allow_any_operation(array)/allow_only_operation()to match specific Storage API operations in one policy.
4. Serve private files with signed URLs
For a private bucket, a client cannot use getPublicUrl() and expect it to work. Supabase's own warning: "This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset" (getPublicUrl() reference). Use a signed URL instead:
// Server-side (or client with a session that already has a SELECT policy match)
const { data, error } = await supabase.storage
.from('user-uploads')
.createSignedUrl('user-id/report.pdf', 60); // expires in 60 seconds
// data.signedUrl is a one-time link good for 60 seconds
createSignedUrl(path, expiresIn, options?) takes the object path and the number of seconds the link should last; options can force a download ({ download: true }) or resize an image ({ transform: { width, height } }) (createSignedUrl() reference). The call itself still checks the caller's select policy on storage.objects, so a signed URL cannot be minted for an object the caller is not allowed to read in the first place — unless it is called with the service_role key, which bypasses RLS and must stay server-side only, never shipped to the browser.
Mistakes we see in AI-generated storage setups
- Bucket created with
public: true"to make the image show up," without checking whether every file in it should be public. getPublicUrl()called on a private bucket, then a fallback added to "fix" the broken image instead of switching tocreateSignedUrl().- No
to authenticated, so a policy meant for signed-in users also evaluates foranon. - Upload path built from a client-supplied user ID instead of the server session, so
(storage.foldername(name))[1]can be set to someone else's ID. updatepolicy withusingbut nowith check, so a file can be renamed into another user's folder.- No file-size or content-type limit at the bucket level, so the per-user quota is whatever the disk allows.
For the table-RLS side of the same app, see the multi-tenant auth.uid() guide and the RLS checklist.
Scan your app free →Copy-paste fix prompt
Audit my Supabase Storage setup. For every bucket: confirm the public flag matches whether its files should be readable by anyone with the URL. Write one storage.objects policy per operation (select, insert, update, delete), scoped "to authenticated", that checks bucket_id and restricts the path to (storage.foldername(name))[1] = (select auth.uid()::text) for per-user folders. Add with check on insert and update. Never call getPublicUrl() on a private bucket; use createSignedUrl() with a short expiry instead, generated server-side with the service_role key only if the caller would not otherwise have a matching select policy. Output SQL migrations.
5. Test before shipping
Before relying on any of the above, confirm with two accounts: user A can read and write only their own folder, user A cannot read or write user B's folder, and a signed-out request gets nothing. If you use pgTAP for your table policies, the same supabase/tests + supabase test db flow from the multi-tenant guide applies to storage.objects policies as well.
FAQ
Does a public Supabase Storage bucket let anyone list every file in it?
Not automatically. Supabase's docs say marking a bucket public only bypasses access control for retrieving and serving a file whose URL is already known. Its own quickstart still writes an explicit SELECT policy for that case, and uploading, deleting, moving and copying remain policy-gated either way.
What happens if I call getPublicUrl() on a private bucket?
Supabase warns that getPublicUrl() does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset. Use createSignedUrl() or an authenticated download for a private bucket instead.
How do I restrict uploads and downloads to each user's own folder?
Store files under a path such as user-uploads/user-id/filename and write policies that check (storage.foldername(name))[1] against the signed-in user, for example (select auth.uid()::text). Supabase's own access control examples use the equivalent auth.jwt()->>'sub' for the same check.
Is a signed URL the same as making the bucket public?
No. A signed URL from createSignedUrl() grants access to one object for a number of seconds you choose. The bucket stays private, every other object stays inaccessible, and the link stops working once it expires.
Does the October 30, 2026 Supabase Data API change affect Storage buckets?
No. That change only removes automatic Data API grants on new tables in the public schema. storage.objects already requires explicit RLS policies before any upload works, public or private, and that is not changing.
Sources
- Supabase docs: Storage buckets fundamentals (checked 2026-10-22)
- Supabase docs: Storage access control (checked 2026-10-22)
- Supabase docs: Storage helper functions (checked 2026-10-22)
- Supabase docs: Creating buckets (checked 2026-10-22)
- Supabase docs: Storage quickstart (checked 2026-10-22)
- Supabase docs: createSignedUrl() reference (checked 2026-10-22)
- Supabase docs: getPublicUrl() reference (checked 2026-10-22)
- Supabase changelog: Tables not exposed to Data and GraphQL API automatically (checked 2026-10-22)
Free, read-only, permission-gated. Nothing stored.