Supabase storage security: public buckets, policies and signed URLs

Supabase storage security comes down to two settings: whether a bucket is public, and the row level security (RLS) policies on the storage.objects table. A public bucket serves every file to anyone who has its URL, with no policy checked. A private bucket checks your policies on every list, download and upload, and before it signs a URL. Keep IDs, invoices and exports in private buckets, one folder per user.

The team behind Keybrum's attacksUpdated 13 min read

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 select permission. A select policy for authenticated that only checks bucket_id lets 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

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:

OperationPublic bucketPrivate bucket
Download a file by its URLAnyone with the URL, no policy checkedOnly if a select policy accepts the caller, or with a signed URL
List the files in a folderOnly if a select policy allows itOnly if a select policy allows it
Upload a new fileOnly with an insert policyOnly with an insert policy
Overwrite or move a fileOnly with update and select policiesOnly with update and select policies
Delete a fileOnly with delete and select policiesOnly with delete and select policies
Create a signed URLNot neededOnly for files a select policy lets the caller read
Permissions as listed in the Supabase Storage docs and the JavaScript reference. The service role key and secret keys skip every policy in both columns.

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.

sql
-- 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;
From Supabase's fix for advisor lint 0025. Replace the policy name with yours.

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:

sql
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'
  ])
);
Without the 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.

sql
-- 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');
Scoped to a bucket, not to a user.

The fix gives each user their own folder and nothing else. Four policies, one per operation:

sql
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)
);
Each user can list, read, sign, upload, replace and delete inside their own folder only. Drop the update and delete policies if users should never change a file once uploaded.
ts
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)
The client puts the user's id first in the path. A random file name keeps personal details out of the path.

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.

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.

ts
// 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)
A short expiry and a link made on demand. Do not store signed URLs in your tables.

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 InvalidJWT error. 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.

FilesExamplesWhat a stranger getsWhere they belong
Identity documentsPassport and ID card scans, driving licences, verification selfiesMaterial for identity theft, often with location data in the photo metadataPrivate bucket, user folder, deleted once verification is done
Invoices and receiptsPDFs generated by your app or uploaded by usersNames, addresses, amounts, sometimes tax numbersPrivate bucket, user or team folder
Data exports and backupsCSV exports of users or orders, database dumpsYour whole customer list in one downloadPrivate bucket with no client policy, read by your server only
Contracts, payslips, health recordsSigned agreements, medical forms, HR filesThe most sensitive data in the appPrivate bucket, short signed URLs only
Chat and message attachmentsImages and files shared in conversationsPrivate conversations, out of contextPrivate bucket, a policy that checks membership of the conversation
Avatars, product photos and blog images are fine in a public bucket, as long as no 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. 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 marked true should only hold files you would be fine seeing posted online.

  2. 2

    Read every policy on storage.objects

    Run the query below. Flag policies whose roles include public or anon on a private bucket, conditions that are only true or only bucket_id = '...', and select policies on public buckets.

  3. 3

    Look for sensitive file names

    Search storage.objects for names that suggest identity, billing or exports, with the second query below. Each hit tells you which bucket needs the strictest policies.

  4. 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. 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 prefix you know has files. An error means the request was refused, or the key or bucket name is wrong.

  6. 6

    Open Security Advisor

    In the dashboard, or with supabase db advisors in 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.

sql
select policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'storage'
  and tablename = 'objects'
order by cmd, policyname;
Every rule that decides who touches your files. A qual that never mentions auth.uid(), a folder or owner_id applies to every caller of the roles listed.
sql
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;
Adjust the patterns to your app and your language.
bash
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}'
Run it against your own project only. With a legacy anon key, also send it as 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 documents table with weak RLS returns file_path for 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_id with auth.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. 1.Storage Buckets, Supabase Docs
  2. 2.Storage Access Control, Supabase Docs
  3. 3.Storage Helper Functions, Supabase Docs
  4. 4.Ownership, Supabase Docs
  5. 5.Serving assets from Storage (signed URLs and revocation), Supabase Docs
  6. 6.URL signing keys, Supabase Docs
  7. 7.JavaScript reference: createSignedUrl and storage permissions, Supabase Docs
  8. 8.Lint 0025: public bucket allows listing, Supabase (splinter on GitHub)
  9. 9.The Storage Schema, Supabase Docs
  10. 10.Database Advisors, Supabase Docs
  11. 11.API keys, Supabase Docs
  12. 12.Official statement from Tea on their data leak, Simon Willison
  13. 13.Tea app suffers breach, exposing thousands of user images, Engadget

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub