Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

Supabase storage file URLs are guessable: how to fix it

FIX / STORAGE URLS / 9 MIN READ

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 Referer header 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.

  1. Open the Supabase dashboard and go to Storage. Note which buckets show the label Public.
  2. Copy the URL of one file from each public bucket.
  3. 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.

  1. 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.

  1. 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.

  1. 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.
  2. 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);
  1. 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.
  2. 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.

  1. Remove the old wide policies. Drop every policy on storage.objects that has only a bucket check, if it grants access to anon or public.
  2. Serve files with signed URLs. Create the URL when the user asks for the file. See the next section.
  3. Delete the old public bucket, or set it to private. Do this after the app uses the new bucket.
  4. Test again from outside. Repeat the curl tests. 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:

  1. Put only public files in it. Never mix private and public files.
  2. Do not add a SELECT policy that lets anonymous callers list the bucket, unless the list is public too.
  3. Keep write policies strict. The access control documentation says that uploads still need an INSERT policy, even in a public bucket.
  4. 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.

  1. Open the Supabase dashboard and go to Logs. The logs documentation lists the log types. Select Storage and API Gateway.
  2. Search the API Gateway log for paths that start with /storage/v1/object/public/. Look for requests to your sensitive buckets.
  3. Look for patterns that show guessing. Examples are many requests to similar paths in a short time, many 404 codes in a row, and one IP address that tries file names in order.
  4. 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.
  5. Check the retention period. The logs documentation says that retention depends on your pricing plan. Old requests may no longer exist.
  6. 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.

  1. Create buckets as private by default. Make a bucket public only when every visitor may see every file in it.
  2. Use one bucket for one purpose. Do not mix public marketing images and private documents.
  3. Store each user file under the user ID. Use the path USER_ID/file and match the first folder to auth.uid() in the policy.
  4. 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.
  5. Review code from AI tools. Search the code for getPublicUrl and for public: true. Ask why each file needs a public URL.
  6. Check file names. Do not put personal data in file names. A name such as john-smith-passport.jpg leaks data in every log.
  7. Do not use sequential file names. Names such as 1.pdf and 2.pdf are 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.
  8. Test from outside after each release. Use the same curl tests 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?
No. A random name is only hard to guess. The URL still works for every person who has it, and it can leak through logs, shared links, and browser history.
Does RLS protect downloads from a public bucket?
No. The Supabase documentation says that RLS does not apply to reading files from a public bucket. RLS still applies to upload, delete, move, and copy.
Can I revoke a signed URL before it expires?
Not by yourself. Supabase says that a signed URL stays valid until the expiry time. To revoke one early, you must contact Supabase support.
Which expiry time must I use for a signed URL?
Use the shortest time that the user needs. Create the URL when the user asks for the file. A value of 60 to 300 seconds works for most downloads.
Should profile pictures be in a private bucket?
Not always. Public avatars that every visitor can see are a valid use of a public bucket. Put anything that belongs to one user or one account 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

SCAN YOUR APP →