RLS Radar

Supabase Storage bucket policies: public vs private

By RLS RadarUpdated Reviewed against official Supabase docs
Short answer

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 authenticated keeps the policy from evaluating for anon at all, same reasoning as table RLS.
  • with check on update stops a user from renaming a file into someone else's folder; using alone 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), and storage.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 to createSignedUrl().
  • No to authenticated, so a policy meant for signed-in users also evaluates for anon.
  • 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.
  • update policy with using but no with 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

Check your live app in seconds

Free, read-only, permission-gated. Nothing stored.

Scan your app

Related guides