BuiltSafely

Home/Fixes

Supabase

Is my Supabase storage bucket public?

A public bucket lets anyone with a file's URL download it, and a bucket-wide storage.objects policy shares every file with every signed-in user. Find both with two read-only queries, then go private with signed URLs and folder-scoped policies.

  • Verified: tested locally
  • Reviewed Oct 2026
  • 12 official sources
Sources Last checked October 26, 2026 · Badge: verified (tested on a throwaway local Supabase Storage API with Supabase Auth and Postgres 17, using two real accounts and a signed-out request) · Sources: official Supabase docs (linked below) · Educational, not an audit

A Supabase Storage bucket has one switch, Public, and a set of RLS policies on the storage.objects table. They control different things. Public means anyone with a file's URL can download it, no sign-in needed. The policies control who can list, upload, overwrite and delete. A private file can leak through either one: the bucket is public, or a policy covers the whole bucket instead of each user's own files.

The fix

1. Check which buckets are public (Verified: run in the SQL editor or psql as the database owner. It's read-only):

-- Which buckets are public, and which SELECT policies reach them?
select b.id as bucket, b.public,
  (select string_agg(p.policyname, ', ')
     from pg_policies p
    where p.schemaname = 'storage' and p.tablename = 'objects'
      and p.cmd in ('SELECT', 'ALL')
      and (coalesce(p.qual, '') like '%''' || b.id || '''%'
           or coalesce(p.qual, '') not like '%bucket_id%')) as select_policies
from storage.buckets b
order by b.public desc, b.id;

Supabase keeps the flag in a boolean public column on storage.buckets (Supabase: Storage schema design). You can also see it in the dashboard under Storage. Any bucket with public = t that holds user uploads (IDs, invoices, receipts, private photos) needs step 3. select_policies lists the select policies that name the bucket or don't filter by bucket at all. On a public bucket, those let people list the files too (see below). In our test, the query flagged our public documents bucket and its bucket-wide read policy, and after the fix showed public = f with only the folder-scoped policy (Verified). It matches the bucket id as text inside the policy, so treat it as a pointer and read the policies themselves in step 2.

2. Find broad policies on storage.objects (Verified, read-only):

-- Permissive storage.objects policies not limited to the signed-in user
with p as (
  select policyname, cmd, roles, qual, with_check,
         coalesce(qual, '') || ' ' || coalesce(with_check, '') as expr
  from pg_policies
  where schemaname = 'storage' and tablename = 'objects'
    and permissive = 'PERMISSIVE'
)
select policyname, cmd, roles, qual as using_expr, with_check as with_check_expr,
  case
    when lower(regexp_replace(coalesce(qual, ''), '[\s()]', '', 'g')) in ('true', '1=1')
      or lower(regexp_replace(coalesce(with_check, ''), '[\s()]', '', 'g')) in ('true', '1=1')
      then 'always true: every file in every bucket'
    when expr not like '%bucket_id%'
      then 'no bucket_id: applies to every bucket'
    when expr !~ '(auth\.uid|auth\.jwt|owner)'
      then 'bucket-wide: not limited to the signed-in user'
  end as concern
from p
where lower(regexp_replace(coalesce(qual, ''), '[\s()]', '', 'g')) in ('true', '1=1')
   or lower(regexp_replace(coalesce(with_check, ''), '[\s()]', '', 'g')) in ('true', '1=1')
   or expr not like '%bucket_id%'
   or expr !~ '(auth\.uid|auth\.jwt|owner)'
order by cmd, policyname;

How to read it: using_expr decides which existing files a role can list, download, overwrite or delete. with_check_expr decides where it can upload (PostgreSQL: pg_policies). In our test it flagged all four broad policies we planted: an update ... using (true) as "always true", and a select, an insert and a delete limited only by bucket_id = 'documents' as "bucket-wide". After the fix it returned 0 rows (Verified). A bucket-wide policy is fine when every signed-in user really should see every file in that bucket (shared team assets, for example). For per-user files, go to step 3. The query only looks for auth.uid, auth.jwt or owner in the rule, so a policy that mentions them can still be wrong. Read each one.

3. Make the bucket private and scope the policies to each user's folder (Verified). This example is for a bucket called documents where each file is stored as <user id>/<file name>. Use your own bucket and policy names from steps 1 and 2:

-- 1. Make the bucket private (or use the dashboard / updateBucket)
update storage.buckets set public = false where id = 'documents';

-- 2. Drop the broad policies (names come from step 2)
drop policy if exists "docs read" on storage.objects;
drop policy if exists "docs upload" on storage.objects;
drop policy if exists "docs update" on storage.objects;
drop policy if exists "docs delete" on storage.objects;

-- 3. Each user only touches files under a folder named with their user id
drop policy if exists "documents: read own folder" on storage.objects;
drop policy if exists "documents: upload to own folder" on storage.objects;
drop policy if exists "documents: update own folder" on storage.objects;
drop policy if exists "documents: delete own folder" on storage.objects;

create policy "documents: read own folder" on storage.objects
  for select to authenticated
  using ( bucket_id = 'documents'
          and (storage.foldername(name))[1] = (select auth.uid()::text) );

create policy "documents: upload to own folder" on storage.objects
  for insert to authenticated
  with check ( bucket_id = 'documents'
               and (storage.foldername(name))[1] = (select auth.uid()::text) );

create policy "documents: update own folder" on storage.objects
  for update to authenticated
  using ( bucket_id = 'documents'
          and (storage.foldername(name))[1] = (select auth.uid()::text) )
  with check ( bucket_id = 'documents'
               and (storage.foldername(name))[1] = (select auth.uid()::text) );

create policy "documents: delete own folder" on storage.objects
  for delete to authenticated
  using ( bucket_id = 'documents'
          and (storage.foldername(name))[1] = (select auth.uid()::text) );

The folder pattern comes from Supabase's access control guide, which restricts uploads with bucket_id = 'my_bucket_id' and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub') (Supabase: Storage access control). storage.foldername() returns the folders in a file's path (Supabase: Storage helper functions). The same guide says upserts need select and update policies as well as insert. That's why all four are here. Your upload code has to put files under the user's id, for example upload(`${user.id}/receipt.pdf`, file). We used folder paths rather than the owner_id column because files uploaded with the service key had an empty owner_id in our test (Verified). The SQL re-ran cleanly a second time (Verified).

The update storage.buckets line is the fix Supabase's linter docs give for a public bucket (Supabase splinter: 0025 public bucket allows listing). In our test the public URL stopped working as soon as it ran, with no restart (Verified, local storage server only). Supabase also says to treat the storage tables as read-only and change things through the API (Supabase: Storage schema design). If you'd rather follow that, flip the bucket to private in the dashboard, or call supabase.storage.updateBucket('documents', { public: false }) from a server with the secret key (Supabase: updateBucket). (Docs-only: we ran the SQL, not the dashboard or updateBucket.)

Tested October 26, 2026 on a throwaway local Supabase Storage API (supabase/storage-api 1.84.23) with Supabase Auth (GoTrue), Postgres 17 and supabase-js storage 2.109, with two real accounts (A and B) and a signed-out request. Before the fix (public documents bucket, the four broad policies): signed out downloaded A's file from its public URL (200) and listed A's folder, B listed and downloaded A's files, uploaded a file into A's folder, overwrote A's file and deleted it. A signed-out upload was refused ("new row violates row-level security policy", 403). After the fix: the public URL returned "Bucket not found", signed out and B listed [] in A's folder, B's download of A's file and B's signed-URL request both returned "Object not found", B's upload and overwrite in A's folder were refused (403), B's delete removed nothing, and A and B could still upload, list and download their own files.

4. Serve private files with signed URLs (Verified). Where the app needs a link (an <img> tag, a download button), create a short-lived signed URL for the signed-in user instead of a public URL:

const { data, error } = await supabase.storage
  .from('documents')
  .createSignedUrl(`${user.id}/receipt.pdf`, 60) // seconds

Supabase says private files can be read with the user's own session, or with a URL from createSignedUrl "that can be accessed for a limited time" (Supabase: Storage buckets, Supabase: createSignedUrl). In our test, A got a signed URL for A's own file, it worked with no sign-in (200), and stopped working after it expired ("exp" claim error, 400). Swapping a different file name into the signed URL failed (400). With the fixed policies, B couldn't get a signed URL for A's file (Verified). Anyone holding a signed URL can use it until it expires, so keep the expiry short.

5. Re-run Security Advisor. Supabase's database linter has a lint for public buckets that also allow listing, 0025_public_bucket_allows_listing (Supabase: Database Advisors). (Docs-only: we ran the queries above, not the hosted Advisor.)

What public does and doesn't mean

  • Public means reads by URL skip your policies. Supabase says a public bucket "effectively bypasses access controls for both retrieving and serving files within the bucket. This means that anyone who possesses the asset URL can readily access the file" (Supabase: Storage buckets). The URL looks like https://[project_id].supabase.co/storage/v1/object/public/[bucket]/[file], and getPublicUrl() generates it for you (Supabase: Serving downloads). In our test, with no select policy at all, a signed-out request still downloaded the file from its public URL (200), and B still downloaded A's file (Verified).
  • Public doesn't open uploads, overwrites or deletes. "Access control is still enforced for other types of operations including uploading, deleting, moving, and copying" (Supabase: Storage buckets). In our public test bucket, signed-out uploads were refused (403) because no policy allowed them (Verified). Those writes depend entirely on your storage.objects policies.
  • Public doesn't list files on its own, but a select policy does. With no select policy, listing A's folder returned [] for signed out and for B, even though the files downloaded by URL (Verified). After we added a select policy with only bucket_id = 'documents', signed out and B could list every file name in A's folder (Verified). Supabase's linter docs say public buckets "are already readable by URL. They do not need a SELECT policy on storage.objects", and a broad one lets clients list every file (Supabase splinter: 0025).
  • Private means every operation goes through RLS. "Buckets are private by default", and in private buckets "all operations are subject to access control via RLS policies. This also applies when downloading assets" (Supabase: Storage buckets). Private buckets "are not accessible via a public URL" (Supabase: Serving downloads). In our test, the public URL of a private bucket's file returned "Bucket not found" (Verified).
  • Private with a bucket-wide policy is still shared. A policy limited only by bucket_id lets every signed-in user read (or write) every file in that bucket, private or not. In our test, B overwrote and deleted A's file through such policies (Verified).

Why it happens

The Public switch is the quick way to make images show up: no signed URLs, no policies, every getPublicUrl() link just works. That's the right call for avatars, logos and product photos, and Supabase lists user profile pictures, public media and blog post content as public-bucket use cases (Supabase: Storage buckets). It's the wrong call once the same bucket starts holding receipts or ID scans.

The policies drift the other way. Supabase's access control guide starts from an insert policy with with check ( true ) and then narrows it to a bucket and a folder (Supabase: Storage access control). Stop halfway, at bucket_id = '...', and every signed-in user shares the whole bucket. Policies on storage.objects are ordinary Postgres RLS policies, so permissive ones are OR'd together (PostgreSQL: CREATE POLICY). One broad policy opens the bucket even when a folder-scoped one sits next to it, the same trap as using (true) on a table.

What not to do

  • Don't put private uploads in a public bucket and rely on hard-to-guess file names. Anyone who sees the URL (a shared link, logs, a browser history) can download the file.
  • Don't add a select policy to a public bucket "to make it work". The files are already readable by URL. The policy only adds listing.
  • Don't add a correct folder policy and leave the bucket-wide one in place. Drop the broad one.
  • Don't use the secret or service_role key in the browser to get past a storage error. "Service keys entirely bypass RLS policies, granting you unrestricted access to all Storage APIs" (Supabase: Storage access control).
  • Don't delete rows from storage.objects to remove files. Supabase says that removes the metadata but not the file itself; use the Storage API (Supabase: Storage schema design).
  • Don't assume files are safe the moment you flip the switch. If private files sat in a public bucket, anyone who had a URL may already have a copy. Supabase also serves Storage through a CDN that caches files (Supabase: Storage CDN). We didn't test the hosted CDN.

AI prompt

I use Supabase Storage. Some buckets hold private user files.
Run these read-only queries and show me the results:
select id, public from storage.buckets order by public desc, id;
select policyname, cmd, roles, qual, with_check
from pg_policies where schemaname = 'storage' and tablename = 'objects';
For each public bucket, tell me whether it holds private files.
For private files, propose: making the bucket private, signed URLs (createSignedUrl with a
short expiry) where the app shows those files, and storage.objects policies that check
bucket_id and (storage.foldername(name))[1] = (select auth.uid()::text) for select, insert,
update and delete. Update my upload code to store files under the user's id.
Do not use a secret or service_role key in client code. Do not delete rows from storage.objects.
Show the SQL and code first and wait for my OK before applying anything.

How to verify (two accounts)

  1. Re-run the queries from steps 1 and 2. Only buckets meant for public files should still be public = t, with no select policy. Step 2 should list only policies you meant to be bucket-wide.
  2. Copy the public URL of one private file (/storage/v1/object/public/<bucket>/<path>) and open it in a private window. Expect an error, not the file.
  3. Sign in as user A and upload a file. Sign in as user B. Confirm B can't list A's folder (empty list), download A's file or get a signed URL for it, and that B's upload into A's folder fails with "new row violates row-level security policy". (A 42501 "permission denied" is a different problem: a missing grant. See 42501 permission denied.)
  4. As A, create a signed URL with a short expiry. Open it signed out: it should work, then fail after it expires.

Our Supabase RLS guide · RLS is on, but my table still leaks · 42501 permission denied · new row violates RLS · Supabase self-check

Your turn

Did step 1 turn up a public bucket you didn't expect? Tell us what kind of files it held (avatars, receipts, IDs). We'll add the patterns we see most.

This guide is educational and based on the vendor's public docs as of the review date. Platforms change: check the linked sources. Not a security audit. Disclaimer.

The BuiltSafely weekly

One email a week. What changed, and the one fix that matters.

Platform changes that affect the security of AI-built apps, checked against official docs. Free. Unsubscribe any time.