Supabase storage file URLs are guessable: how to fix it
Supabase Storage URLs in a public bucket are not secret, so make the bucket private and serve each file with a short-lived signed URL. A public bucket needs no login to read a file. The file name does not protect the file. Any person who has the URL can open it.
This guide shows how the URLs work, how to test your buckets, and how to fix them. It also shows how to check the logs and how to stop the problem in new buckets.
Are Supabase storage file URLs guessable?
Some are guessable, and all of them are open when the bucket is public. The word “guessable” hides the real problem. The real problem is that a public bucket has no access check at all.
The URL of a file in a public bucket has this form, as described in the Supabase downloads documentation:
https://[project_id].supabase.co/storage/v1/object/public/[bucket]/[asset-name]
Three parts of the URL are easy to learn. The project ID is visible in your app code. The bucket name is visible in your app code. The file name often follows a pattern, such as invoice-1042.pdf, avatar.png, or user-id/passport.jpg.
The Supabase buckets documentation states that a public bucket “effectively bypasses access controls for both retrieving and serving files.” The same page says that anyone who has the URL can read the file.
A random file name makes guessing harder. It does not add a check. The URL can leak in other ways:
- A user shares a link in a chat or an email.
- A browser stores the URL in its history.
- A support tool or an error tracker records the URL.
- A web page that shows the file sends the URL in a
Refererheader to other sites. - An AI feature or a log service receives the URL in a request.
So the safe rule is simple. If a file is not meant for every visitor, do not put it in a public bucket.
What is different in a private bucket?
A private bucket has no public URL. The downloads documentation gives two ways to read a file:
| Method | What the caller needs | Typical use |
|---|---|---|
| Authenticated request | The user JWT in the Authorization header and a matching RLS policy |
Your own app code reads the file for the signed-in user |
| Signed URL | A URL that your server created with createSignedUrl |
A link for a download, an image tag, or a share |
In a private bucket, RLS policies on storage.objects control every operation, including downloads.
How do I test if my files are open?
Test from outside your app, with no login and no cookies. Use a terminal or a private browser window.
- Open the Supabase dashboard and go to Storage. Note which buckets show the label Public.
- Copy the URL of one file from each public bucket.
- Request the file without any header.
curl -I "https://YOUR_PROJECT.supabase.co/storage/v1/object/public/BUCKET/PATH/file.pdf"
A 200 status code means that anyone can read the file. A private bucket returns an error for this URL.
- Check whether the bucket allows file lists. Use the anon key, which is public in your app code.
curl -s -X POST "https://YOUR_PROJECT.supabase.co/storage/v1/object/list/BUCKET" \
-H "apikey: YOUR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"prefix":"","limit":100}'
The endpoint is the one that the list() method of the Supabase client calls. If the response contains file names, an anonymous caller can find every file. The attacker then does not need to guess names.
According to the access control documentation, a SELECT policy on storage.objects controls reads and file lists. A policy that is too wide on SELECT is the usual cause of an open list.
- Read your own policies. Run this query in the SQL editor:
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'storage' and tablename = 'objects';
Look for qual values that are only bucket_id = 'your-bucket'. This policy gives access to every file in the bucket, to every role in the roles column. If roles includes public or anon, an anonymous caller can use it.
Note: The anon key is not a secret. The anon key guide explains why. Your policies must be safe even when a stranger holds the anon key.
How do I fix it now?
Do these steps in order. Steps 1 to 3 close the open files. The other steps move your app to the safe design.
Caution: If you make a bucket private, every stored public URL stops working. Images and links in your app and in old emails break. Update the app code before you change the bucket, or accept a short outage.
- List what is in the bucket. Decide which files are meant for every visitor. Examples are logos and public product images. Everything else is private data.
- Create a new private bucket for private files. Use the dashboard, or run SQL:
insert into storage.buckets (id, name, public)
values ('documents', 'documents', false);
- Move the private files into the new bucket. Use the Storage dashboard, or copy the files with the Storage API. Keep a folder per user, with the user ID as the first folder name. The path is then
USER_ID/file.pdf. - Add policies for the private bucket. Each user must read and write only the files in their own folder. The helper
storage.foldername(name)returns the folder names of a path. The access control documentation uses it for per-user folders.
create policy "users read own files"
on storage.objects for select to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
create policy "users upload own files"
on storage.objects for insert to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
The access control documentation says that an upload needs only an INSERT policy. An upload with upsert also needs SELECT and UPDATE policies. Add an UPDATE policy with the same folder check if your app overwrites files. Add a DELETE policy only if users must delete files.
- Remove the old wide policies. Drop every policy on
storage.objectsthat has only a bucket check, if it grants access toanonorpublic. - Serve files with signed URLs. Create the URL when the user asks for the file. See the next section.
- Delete the old public bucket, or set it to private. Do this after the app uses the new bucket.
- Test again from outside. Repeat the
curltests. The old public URLs must now fail, and the list request must return no names.
For a full guide to RLS policies on tables, read the Supabase RLS guide. This article covers only the Storage part.
How do I create a signed URL?
Call createSignedUrl with the file path and the number of seconds until expiry. The client reference shows this form:
const { data, error } = await supabase
.storage
.from('documents')
.createSignedUrl(`${user.id}/report.pdf`, 60)
if (error) throw error
console.log(data.signedUrl)
The second argument is the number of seconds. The value 60 gives one minute.
Follow these rules:
- Create the URL on demand. Do not store signed URLs in your database. A stored URL expires, or it lives too long.
- Use a short expiry. Choose the shortest time that the user needs. A download starts at once, so one minute is often enough.
- Create the URL for the right user. If you create it in an Edge Function or a server route, check who the caller is first. Then check that the caller owns the file path.
- Never create a signed URL from a path that the browser sends without a check. A caller could ask for
other-user/secret.pdf.
The downloads documentation says that signed URLs use a separate internal key. It also says that a rotation of your Auth keys does not invalidate them. A signed URL stays valid until the expiry time. To revoke one early, you must contact Supabase support. This is the reason for a short expiry.
Warning: Do not use the service_role key in the browser to create signed URLs. That key bypasses RLS and gives full access. If you suspect that the key leaked, read the service_role key guide.
When is a public bucket the right choice?
A public bucket is correct for files that every visitor may see. Examples are the logo of your product, marketing images, and public documentation files. Use these rules for a public bucket:
- Put only public files in it. Never mix private and public files.
- Do not add a
SELECTpolicy that lets anonymous callers list the bucket, unless the list is public too. - Keep write policies strict. The access control documentation says that uploads still need an
INSERTpolicy, even in a public bucket. - Limit the file types and the file size in the bucket settings, so that nobody uses your bucket as free storage.
How do I check if someone already read my files?
You can only see the requests that the logs recorded. The logs do not prove that nobody read a file.
- Open the Supabase dashboard and go to Logs. The logs documentation lists the log types. Select Storage and API Gateway.
- Search the API Gateway log for paths that start with
/storage/v1/object/public/. Look for requests to your sensitive buckets. - Look for patterns that show guessing. Examples are many requests to similar paths in a short time, many
404codes in a row, and one IP address that tries file names in order. - Look for list calls. Search for
/storage/v1/object/list/. An anonymous list call to a private-data bucket means that someone could see all file names. - Check the retention period. The logs documentation says that retention depends on your pricing plan. Old requests may no longer exist.
- Check other places. Read the logs of your CDN, your host, and any proxy in front of the project.
Make a list of the files that were exposed. If these files contain personal data, such as IDs, contracts, health data, or invoices, treat the case as a possible data breach. Ask a lawyer about your duty to tell the affected users.
Note: A user who opened a public URL in a normal way also appears in the logs. You cannot tell a normal visit from a visit by an attacker unless you compare it with your own app traffic.
How do I stop this problem in new buckets?
Use these rules for every new bucket.
- Create buckets as private by default. Make a bucket public only when every visitor may see every file in it.
- Use one bucket for one purpose. Do not mix public marketing images and private documents.
- Store each user file under the user ID. Use the path
USER_ID/fileand match the first folder toauth.uid()in the policy. - Write the policy before the upload code. Without a policy, a private bucket rejects uploads. This is a safe default. Do not add a wide policy only to make an error go away.
- Review code from AI tools. Search the code for
getPublicUrland forpublic: true. Ask why each file needs a public URL. - Check file names. Do not put personal data in file names. A name such as
john-smith-passport.jpgleaks data in every log. - Do not use sequential file names. Names such as
1.pdfand2.pdfare easy to enumerate. Use a random UUID for the stored name. Keep the original name in a database column. A random name is not access control. It only removes the easy guesses, and the policy does the real work. - Test from outside after each release. Use the same
curltests as above. Add them to your release checklist.
The BOLA guide for AI-generated CRUD explains the same type of problem for database rows. An app that lets a user change an ID in a request to read data of another user has the same cause as an open bucket.
When should I get help?
Get help in these cases:
- The files in the bucket hold personal data, health data, or payment data, and the logs show requests from unknown sources.
- You cannot tell which policies are active, or you do not know which roles can use them.
- You need to revoke a signed URL that you created with a long expiry. Contact Supabase support with the project ID and the file path.
- The app is in production and you cannot make the bucket private without a long outage. Ask an engineer to plan the move in steps.
- You must decide if you have a legal duty to report a breach. Ask a lawyer who knows the rules of your region.
After the fix, run the outside tests one more time. A private bucket must refuse the old public URL. The list request with the anon key must return no file names.
Common questions
Is a long random file name enough to protect a file?
Does RLS protect downloads from a public bucket?
Can I revoke a signed URL before it expires?
Which expiry time must I use for a signed URL?
Should profile pictures be in a private bucket?
Test your storage buckets
A VibeEval scan requests your Supabase Storage paths without a login and reports buckets that return files or file lists to anonymous callers.
14-day free trial · No credit card · Cancel anytime