Supabase storage bucket is public: how to make it private
A public Supabase Storage bucket serves every file to anyone who has the file URL, so set the bucket to private and serve files with signed URLs. The public setting turns off access control for downloads. A storage policy cannot block a download from a public bucket. The fix has three parts: change the bucket setting, write policies for the other operations, and change the app code that builds the file URLs.
What does “Supabase storage bucket public” mean?
It means that the bucket has the public setting turned on. Supabase defines this setting in the storage fundamentals guide. A public bucket bypasses the access controls for retrieving and serving files. Anyone who has the URL of a file can download it.
A private bucket works in the opposite way. Buckets are private by default. When you create a bucket in code, you must pass public: true to make it public.
Vibe coding tools often create public buckets. You ask the tool for an avatar upload. The tool creates a bucket, sets public to true, and uses a plain URL in the image tag. The feature works at once, and no policy is needed for the download. The same bucket then holds invoices, ID scans, or private documents, because the tool reuses it.
The public setting does not change the rules for write operations. The Supabase guide states that access control still applies to uploading, deleting, moving, and copying. A public bucket does not let strangers upload files. It lets strangers download them.
How do I confirm that my bucket is public?
Use these checks. Start with the dashboard, and then test from outside your app.
- Open the Storage page. Open the Supabase Dashboard and select Storage. Each bucket shows a public label when the setting is on.
- Query the bucket table. Run this query in the SQL Editor:
select id, name, public
from storage.buckets
order by public desc, name;
A row with public set to true is a public bucket. Check every row. A project often has an old test bucket that nobody remembers.
- Find the file URLs in your app. The Supabase downloads guide gives the format of a public URL. The URL has this form:
https://<project_ref>.supabase.co/storage/v1/object/public/<bucket>/<file-path>
Open your live app and use DevTools. Select the Network tab and reload the page. Search for /object/public/. Each match is a file from a public bucket.
-
Test from a signed-out browser. Copy one file URL. Open a private browser window that has no session for your app. Paste the URL. If the file loads, anyone can read it.
-
Test with curl. Send a request without any token:
curl -s -o /dev/null -w "%{http_code}\n" \
"https://<project_ref>.supabase.co/storage/v1/object/public/documents/test.pdf"
The code 200 means that the file is open. A private bucket does not answer on the public path.
- Check if the file list is open. A broad
SELECTpolicy onstorage.objectscan let a client list the file names in a bucket. Run this code in a script with only the public key:
const { data, error } = await supabase.storage.from("documents").list();
console.log(data, error);
If the call returns file names, an attacker can read the names and download each file. The Supabase access control guide warns about this case. It says that a bucket listing is possible when the SELECT policy does not limit the operation.
Note: A public bucket is correct for logos and marketing images. The problem starts when a bucket holds files that only one user may see.
What do I do first? Fix it now, in order of urgency
Do these steps in order. Steps 1 and 2 stop new downloads. The other steps keep your app working and stop the problem from coming back.
Caution: When you set a bucket to private, every public URL that your app uses stops working. Images can disappear from your pages until you deploy the code change in step 4. Do the steps in one work session.
-
List the files in each public bucket. Decide which files are safe for everyone. Move the other files to a new private bucket, or keep them in place and make the bucket private. For a mixed bucket, a new private bucket is the cleaner choice.
-
Set the bucket to private. Use one of these methods:
In the dashboard, open Storage, open the bucket settings, and turn off the public setting.
In SQL, run:
update storage.buckets
set public = false
where id = 'documents';
In JavaScript, the updateBucket method takes the same option:
await supabase.storage.updateBucket("documents", { public: false });
Run the JavaScript code only on a server with the secret key. Do not run it in the browser.
- Write policies for the private bucket. A private bucket allows no uploads until a policy exists. Supabase states that Storage does not allow uploads to buckets without RLS policies. The next section shows the policies.
- Change the app code. Replace each public URL with a signed URL, or with an authenticated download. See the section on signed URLs below.
- Deploy the change and test. Open the app as a signed-in user and confirm that the files load. Then open a signed-out window and confirm that the old URL returns an error.
- Check the cache. A browser, a CDN, or a proxy can keep a copy of a file that was public. Clear the cache of your own CDN if you use one. You cannot clear the copy of a person who already downloaded the file.
- Check the damage. Use the section below to review the logs.
If the files hold sensitive data, such as ID scans or contracts, treat the exposure as a data incident. Do not wait for proof that someone downloaded the files.
How do I write storage policies for a private bucket?
Storage policies are RLS policies on the storage.objects table. Each policy must name the bucket with bucket_id. Without it, the policy applies to every bucket in the project.
The most common design stores each user’s files in a folder that has the user ID as the name. The path of a file is then <user_id>/invoice.pdf. The Supabase access control guide shows this pattern with the helper storage.foldername. The helper returns the folders in the file path as an array.
This policy lets a signed-in user upload only into the folder of that user:
create policy "users 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)
);
This policy lets a signed-in user read only the files in that folder:
create policy "users read own folder"
on storage.objects
for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
Add policies for update and delete in the same way. Each policy needs the bucket check and the folder check. An overwrite with upsert needs the select, insert, and update policies together.
Warning: Do not write using (true) or using (bucket_id = 'documents') for the select operation on a private bucket. Such a policy lets every signed-in user read and list every file in the bucket. Always add the folder check or a check on an owner column.
Policies work together. If two policies apply to the same operation, a file is allowed when any one policy allows it. A broad old policy can undo a strict new one. List all policies on the table and delete the old ones:
select policyname, cmd, roles, qual
from pg_policies
where schemaname = 'storage' and tablename = 'objects';
If your files belong to teams and not to single users, use a lookup table. The policy then checks that the user is a member of the team that owns the folder. Keep that lookup simple. For a full guide to RLS patterns, read the Supabase RLS guide.
How do I serve files from a private bucket?
A private bucket has no public URL. You have two safe options. Both keep access control in place.
Option A: Signed URL
A signed URL gives access to one file for a limited time. The Supabase downloads guide shows the createSignedUrl method. The second argument is the lifetime in seconds.
const { data, error } = await supabase.storage
.from("documents")
.createSignedUrl("USER_ID/invoice.pdf", 300);
if (error) throw error;
console.log(data.signedUrl);
Use a short lifetime. A value of 60 to 300 seconds is enough for a download or a page view. A long lifetime is almost the same as a public URL, because anyone who gets the link can use it until it expires.
You can create the signed URL in the browser if the user has a select policy for the file. You can also create it on the server, for example in an Edge Function, after your own checks. Use the server option when the access rule is more complex than a folder check.
Option B: Authenticated download
The signed-in user can download the file with the access token. The downloads guide documents an authenticated path:
https://<project_ref>.supabase.co/storage/v1/object/authenticated/<bucket>/<file-path>
The request must carry the Authorization header with the token of the user. The client library does this for you when you call download:
const { data, error } = await supabase.storage
.from("documents")
.download("USER_ID/invoice.pdf");
What about images in a page?
An image tag cannot send an Authorization header. For private images, create a signed URL and put it in the src attribute. Create a new URL when the user opens the page. Do not store signed URLs in the database, because they expire.
How do I check the damage?
You need to know which files were open and who downloaded them. The answer has limits. Some downloads leave no record that you can read.
- List what was exposed. Run the query from the confirm section. Count the files in each public bucket. Write down the first date that the bucket was public. The creation date of the bucket is the latest possible start date.
- Open the project logs. The Supabase logs guide names the log sources. The sources include API Gateway and Storage. Open Logs in the Dashboard and select the Storage source.
- Look for download requests. Search for requests to the
object/publicpath. Check the paths, the status codes, and the time. Compare the request count with the number of users that you expect. - Look for list requests. A large number of list calls from one address can mean that someone read the file names before the downloads.
- Check your CDN logs. A CDN in front of your project may hold its own request log.
- Check how long the logs go back. Retention depends on your pricing plan, according to the logs guide. If the period of exposure is longer than the retention, you cannot prove that no one downloaded a file.
- Check for other exposed data. Files often contain the same data as your tables. If the files were open, check your tables too. Read the Supabase anon key guide to test what an anonymous caller can read.
If the files held a password, an API key, or a token, rotate it now. If the bucket held personal data, you may have a legal duty to tell the affected people. Ask a legal adviser in your region.
How do I stop the next public bucket?
Use these controls in each project.
- Create buckets as private. Do not pass
public: trueunless every visitor may see the files. The default is private. - Use two buckets. Keep one public bucket for assets such as logos. Keep one private bucket for user files. Never mix the two.
- Use random file names for user files. A random name does not replace access control. It makes guessing harder.
- Add the bucket check to every policy. Each
storage.objectspolicy needsbucket_id = '<name>'. - Use a user folder or an owner check. Never use a policy that allows every signed-in user to read every file.
- Review AI-generated code. Search your repository for
getPublicUrl,public: true, and/object/public/. Check each match against the type of file. - Review each release. Run this query after each schema change, and compare it with the last result:
select id, public from storage.buckets where public;
- Scan the live app. A scan of the deployed app finds public file URLs that source code review misses. Read the production security checklist for more checks to run before a release.
When should I get help?
Get help in these cases:
- The bucket held ID scans, health data, contracts, or payment records. Ask a security engineer to review the app and a legal adviser to review your duties.
- The logs show many downloads or list calls from addresses that you do not know.
- Your log retention is shorter than the exposure period, so you cannot prove what happened.
- The bucket policies are complex and you cannot tell which policy allows which access. A review of the
pg_policiesoutput by an expert saves time. - You find files that you did not upload. This can mean that a write policy is also open.
After the fix, run the curl test from the confirm section again. The old public URL must return an error. Then sign in as two different test users and confirm that each user can read only the files in the folder of that user.
Common questions
Are files in a public Supabase bucket really open to everyone?
Do RLS policies protect downloads from a public bucket?
Can people guess the URL of a file in a public bucket?
Do I need to change my code when I make the bucket private?
Is a public bucket ever correct?
Find open storage buckets
A VibeEval scan reads the storage URLs that your app uses and reports buckets that serve files without a signed URL or a login.
14-day free trial · No credit card · Cancel anytime