In short
- A public bucket serves every file to anyone who has its URL, with no policy checked. Its policies still govern listing, uploads, moves and deletes.
- Buckets are private by default. In a private bucket, every list, download and signed URL goes through the RLS policies on
storage.objects. - Listing files, downloading them and creating signed URLs all use the
selectpermission. Aselectpolicy forauthenticatedthat only checksbucket_idlets every signed-in user list and read every file in the bucket. - A signed URL works for anyone who holds it until it expires. You cannot revoke a single link: delete or move the file, or rotate the URL signing key, which is separate from your Auth keys.
- ID scans, invoices and data exports belong in a private bucket, in a folder named after the user's id, with policies that compare that folder to
auth.uid().
On this page
- What Supabase storage security comes down to
- Public or private bucket: what "public" really allows
- Listing files and downloading files are two permissions
- Storage policies that keep each user in their own folder
- Signed URLs: how long should a link live?
- Which files must never sit in a public bucket
- How to audit your Supabase buckets
- The traps people miss after the obvious fix
- What Keybrum tests on your buckets
- Questions
- Sources
What Supabase storage security comes down to
Supabase Storage keeps your files in buckets, and keeps a row for each file in a Postgres table, storage.objects, in the storage schema of your project. Every bucket has one switch that matters more than any other: public or private. Buckets are private by default. Everything else is decided by row level security (RLS) policies on storage.objects: who can upload, list, download, move and delete. Storage refuses every upload until you write a policy that allows it.
It is the same model as your tables, so the same mistakes happen. A bucket gets flipped to public because image previews were broken. A policy says bucket_id = 'documents' and nothing else. A signed link valid for ten years ends up in a table. Teams that checked every table for RLS often never open the storage policies, which makes buckets the forgotten half of Supabase security. The Supabase RLS guide covers the tables; this one covers the files.
Public or private bucket: what "public" really allows
Supabase's documentation says it plainly: a public bucket bypasses access control for retrieving and serving files, so anyone who has a file's URL can read it. Public files are served at https://YOUR_PROJECT_REF.supabase.co/storage/v1/object/public/BUCKET/PATH and cached by the CDN. Access control still applies to the other operations. Here is what each kind of bucket allows:
| Operation | Public bucket | Private bucket |
|---|---|---|
| Download a file by its URL | Anyone with the URL, no policy checked | Only if a select policy accepts the caller, or with a signed URL |
| List the files in a folder | Only if a select policy allows it | Only if a select policy allows it |
| Upload a new file | Only with an insert policy | Only with an insert policy |
| Overwrite or move a file | Only with update and select policies | Only with update and select policies |
| Delete a file | Only with delete and select policies | Only with delete and select policies |
| Create a signed URL | Not needed | Only for files a select policy lets the caller read |
Public buckets suit files meant for everyone: Supabase's examples are profile pictures, public media and blog content. They are the wrong place for anything a user uploads expecting privacy, and "nobody knows the URL" does not change that. Paths end up in table rows, HTML, emails and logs, and once one leaks, a public bucket has no second lock.
Listing files and downloading files are two permissions
Storage maps several actions onto the same SQL privilege. Listing the files in a folder needs select on storage.objects. So does downloading from a private bucket, and so does creating a signed URL. A single broad select policy therefore gives three things at once: the list of every file name, the files themselves and the ability to share them by link.
Listing is often the worse leak. A download needs the exact path; a listing hands it over. File names in user uploads are rarely neutral: passport_scan.jpg, invoice_2026_10.pdf, users_export.csv. For a public bucket, Supabase's own advisor treats a broad select policy as a mistake (lint 0025, "public bucket allows listing"): public URLs keep working without it, so the policy only adds the ability to enumerate.
-- Public bucket: public URLs work without any select policy.
-- A policy like this only adds listing, so remove it.
drop policy if exists "Public bucket listing" on storage.objects;In a private bucket where some files must be readable by path but not listable, Supabase provides two helpers: storage.allow_only_operation() and storage.allow_any_operation(). They let a select policy apply to some Storage actions and not others. This example from the docs lets any client read files in an avatars bucket by path, without being able to list the bucket:
create policy "Avatar files readable by path, not listable"
on storage.objects for select
using (
bucket_id = 'avatars'
and storage.allow_any_operation(array[
'object.get_authenticated_info',
'object.get_authenticated'
])
);allow_any_operation filter, the same policy would also let anyone list the bucket.Storage policies that keep each user in their own folder
The pattern that works for most apps: one private bucket per kind of file, and one folder per user inside it, named after the user's id. Uploads go to USER_ID/filename, and every policy compares the first folder of the path with the caller's id. The helper storage.foldername(name) returns the folders of a path as an array, so (storage.foldername(name))[1] is the top folder.
Start with the policy that often gets written first. It looks scoped, because it names the bucket, but it lets every signed-in account read, list and sign every file. On most apps anyone can create an account, so that means every stranger who signs up: the storage version of an IDOR vulnerability.
-- Too broad: any signed-in user reads and lists every file in the bucket
create policy "Users can read documents"
on storage.objects for select
to authenticated
using (bucket_id = 'documents');The fix gives each user their own folder and nothing else. Four policies, one per operation:
create policy "Read own documents"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
create policy "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 "Replace own documents"
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 "Delete own documents"
on storage.objects for delete
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not signed in')
await supabase.storage
.from('documents')
.upload(`${user.id}/${crypto.randomUUID()}.pdf`, file)Supabase also records an owner for each file: owner_id holds the sub claim of the token that uploaded it, so owner_id = (select auth.uid()::text) works as a condition too. One limit: files uploaded with the service role key or from the dashboard get no owner, so invoices that an Edge Function uploads with that key stay hidden from their user under an owner_id policy. Folder names work in both cases.
Each bucket can also set a maximum file size and a list of allowed MIME types: set both wherever users upload. And if uploads with upsert: true fail, add select and update policies scoped to the same folder, as above, never broad ones: Supabase requires both to overwrite a file.
Signed URLs: how long should a link live?
A signed URL gives access to one file in a private bucket for a fixed time, without a login. You create it with createSignedUrl(path, expiresIn), where expiresIn is in seconds. The caller needs select on that file, so with the folder policies above a user can only sign their own files. Whoever holds the link can open the file until it expires: it can be forwarded, pasted in a support ticket or kept in browser history.
// Create the link when the user clicks, valid for one minute
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl(path, 60)
if (data) window.location.assign(data.signedUrl)What makes expiry matter is that you cannot revoke a single signed URL. Supabase's documentation gives two ways out:
- For one file: delete, move or rename the object. The old link stops working.
- For every link at once: open URL Signing Keys in the dashboard, rotate to a standby key (create one first if there is none), then revoke the previous key. Existing links then fail with an
InvalidJWTerror. Files already served may still come from the CDN cache until you purge it. - What does not work: rotating your Auth JWT signing keys or legacy JWT secret, or disabling the legacy API keys. Storage signs URLs with its own key, so signed URLs survive all of those. Keep that in mind when you follow the procedure for a leaked service role key.
Pick the expiry by use: about a minute for a download button, an hour for a page that embeds a private image, a few days at most for an emailed link. A link valid for a year is a public URL with a date on it. Signed upload URLs (createSignedUploadUrl) last two hours.
Which files must never sit in a public bucket
The files that cause real harm when a bucket is open are the ones apps collect for compliance, billing or support. They also tend to carry the most telling file names.
| Files | Examples | What a stranger gets | Where they belong |
|---|---|---|---|
| Identity documents | Passport and ID card scans, driving licences, verification selfies | Material for identity theft, often with location data in the photo metadata | Private bucket, user folder, deleted once verification is done |
| Invoices and receipts | PDFs generated by your app or uploaded by users | Names, addresses, amounts, sometimes tax numbers | Private bucket, user or team folder |
| Data exports and backups | CSV exports of users or orders, database dumps | Your whole customer list in one download | Private bucket with no client policy, read by your server only |
| Contracts, payslips, health records | Signed agreements, medical forms, HR files | The most sensitive data in the app | Private bucket, short signed URLs only |
| Chat and message attachments | Images and files shared in conversations | Private conversations, out of context | Private bucket, a policy that checks membership of the conversation |
select policy makes it listable.Photos from phones often keep GPS coordinates in their EXIF metadata: Simon Willison noted that many of the leaked Tea photos did, which turned a leak of ID photos into a map of users. Strip metadata on upload. For exports, the safest bucket has no policy at all for anon and authenticated: only your server reads it, with a secret key, and hands the user a short signed URL.
How to audit your Supabase buckets
Six checks. The first four run in the SQL editor of your own project and change nothing. The last two look at it from outside.
- 1
List your buckets and which ones are public
Run
select id, public, file_size_limit, allowed_mime_types from storage.buckets order by public desc, id;. A bucket markedtrueshould only hold files you would be fine seeing posted online. - 2
Read every policy on storage.objects
Run the query below. Flag policies whose roles include
publicoranonon a private bucket, conditions that are onlytrueor onlybucket_id = '...', andselectpolicies on public buckets. - 3
Look for sensitive file names
Search
storage.objectsfor names that suggest identity, billing or exports, with the second query below. Each hit tells you which bucket needs the strictest policies. - 4
Find file paths stored in your tables
List the columns that hold paths or URLs with
select table_name, column_name from information_schema.columns where table_schema = 'public' and (column_name ilike '%path%' or column_name ilike '%url%' or column_name ilike '%file%');. If one of those tables is readable with the public key, the paths are public too. - 5
List a private bucket as a stranger
Run the curl below against your own project, with your public key and no user token. Names in the answer, files or folders such as user ids, mean anyone can enumerate that bucket. An empty array means the policies hid everything or the folder is empty, so try a
prefixyou know has files. An error means the request was refused, or the key or bucket name is wrong. - 6
Open Security Advisor
In the dashboard, or with
supabase db advisorsin the CLI. It warns on public buckets that allow listing, next to the table checks. Treat each warning as a lead and confirm it with the checks above.
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'storage'
and tablename = 'objects'
order by cmd, policyname;qual that never mentions auth.uid(), a folder or owner_id applies to every caller of the roles listed.select bucket_id, name, created_at
from storage.objects
where name ~* '(passport|licen[cs]e|id_?card|selfie|invoice|receipt|payslip|contract|export|backup|dump|[.]csv$|[.]sql$)'
order by created_at desc
limit 100;curl -X POST "https://YOUR_PROJECT_REF.supabase.co/storage/v1/object/list/documents" \
-H "apikey: YOUR_PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"prefix": "", "limit": 10}'Authorization: Bearer YOUR_ANON_KEY.One more check takes a minute: open the public URL of a file from a bucket you believe is private, in a private browser window. If the file opens, the bucket is public, whatever your code assumes. Note that getPublicUrl() in the SDK builds that URL without checking whether the bucket is public, so finding it in your code is a hint worth following.
The traps people miss after the obvious fix
- Paths leak through tables. Listing is blocked, but a
documentstable with weak RLS returnsfile_pathfor every row. In a public bucket, each path is a download. Fix the table with the RLS guide and keep private files in private buckets, so a known path alone opens nothing. - Policies for `authenticated` without a user check. "Only signed-in users" means anyone who signs up, and with anonymous sign-ins enabled, anyone at all. Every storage policy on private files should compare the path or
owner_idwithauth.uid(). - Making a bucket private breaks public links. Links built with
getPublicUrl()stop working once the bucket is private. Move the app to signed URLs first, then switch the bucket, or a coding agent asked to fix the broken images may flip it back to public. - Old signed URLs stored as data. A signed URL saved in a row lives as long as its expiry. Store the path, sign it when the user asks.
- A secret key in the frontend. The service role key and
sb_secret_keys skip every storage policy, so none of the above matters while one sits in your bundle. The anon key guide explains which key belongs where.
What Keybrum tests on your buckets
The SQL above shows what your policies say. Keybrum checks what they do on the live app, as an anonymous visitor and as a freshly signed-up account. It finds the buckets your code names or the Storage API lists, walks their folders, flags identity and financial documents, and downloads one byte of one file as proof, storing no file contents. It tries file paths that readable table rows expose, even when the bucket cannot be listed. And it tests whether a stranger can add files by re-posting to a path that already exists, so the attempt can only collide: nothing is created or overwritten.
Each finding comes with the request that worked and the fix, which your coding agent reads through Keybrum's MCP server. For the rest of the stack, tables, functions, Realtime and Auth, see the Supabase security guide.
Questions
Is a Supabase public bucket safe?
For files meant to be public, such as avatars or product photos, yes. A public bucket serves every file to anyone who has its URL, with no policy checked, so it is the wrong place for anything private. Policies still control uploads, moves and deletes, and a select policy on a public bucket only adds the ability to list its files.
How do I make a Supabase bucket private?
Turn off the bucket's public setting in the Storage section of the dashboard, or run update storage.buckets set public = false where id = 'your_bucket'; as Supabase's advisor docs suggest. Then add select policies on storage.objects for the files each user should read, and replace getPublicUrl() links with signed URLs.
Does Supabase storage use RLS?
Yes. Storage access is controlled by RLS policies on the storage.objects table, written in SQL like the policies on your own tables. Without an insert policy nobody can upload, and in a private bucket nobody can list or download a file unless a select policy allows it. The service role key and secret keys skip these policies.
Where do I write Supabase storage policies?
On the storage.objects table, from the SQL editor or the Storage section of the dashboard. Policies can use the helpers storage.foldername(), storage.filename() and storage.extension(), and the owner_id column. Do not alter the storage schema itself: Supabase asks you to treat it as read-only, apart from adding indexes.
Can someone list all the files in my Supabase bucket?
Only if a select policy on storage.objects allows it for their role, in a public or a private bucket. A policy that checks only bucket_id lets every caller of that role list everything. Use per-user folder conditions, or the storage.allow_only_operation() and storage.allow_any_operation() helpers to allow downloads without listing.
Can I revoke a Supabase signed URL?
Not one by one. To kill the link to one file, delete, move or rename the file. To kill every signed URL, rotate the URL signing key in the dashboard and revoke the previous one; rotating your Auth keys does not affect signed URLs.
How long should a Supabase signed URL last?
As short as the use allows: about a minute for a download button, an hour for an embedded image, a few days at most for an emailed link. The expiry is set in seconds when you call createSignedUrl. Create links on demand rather than storing them, because a stored link stays valid until it expires.
Sources
- 1.Storage Buckets, Supabase Docs
- 2.Storage Access Control, Supabase Docs
- 3.Storage Helper Functions, Supabase Docs
- 4.Ownership, Supabase Docs
- 5.Serving assets from Storage (signed URLs and revocation), Supabase Docs
- 6.URL signing keys, Supabase Docs
- 7.JavaScript reference: createSignedUrl and storage permissions, Supabase Docs
- 8.Lint 0025: public bucket allows listing, Supabase (splinter on GitHub)
- 9.The Storage Schema, Supabase Docs
- 10.Database Advisors, Supabase Docs
- 11.API keys, Supabase Docs
- 12.Official statement from Tea on their data leak, Simon Willison
- 13.Tea app suffers breach, exposing thousands of user images, Engadget