Securing Supabase Storage Buckets: Private Files and Signed URLs

  • Thursday, October 22, 2026

Securing Supabase Storage buckets: when to use public or private, how signed URLs really behave behind the CDN, and policies that stop file leaks.

Every Supabase Storage bucket starts out private, and switching one to public hands every file inside it to anyone who has the URL. Securing Supabase Storage comes down to three decisions: which buckets are public, what policies you write on the storage.objects table, and how long your signed URLs stay valid.

This guide is for founders and small teams running a Supabase app, including apps generated with Lovable, Bolt or Cursor, where uploads work but nobody has checked who can read the files afterwards. It covers the public-or-private choice, the policies files need, how signed URLs behave once a CDN is involved, and how to test a bucket from outside your own app. If your database tables are the open question rather than your files, start with our walkthrough of finding and closing row-level security gaps.

Key takeaways

  • Buckets are private by default. Marking one public removes read access control entirely, so a long random filename is obscurity rather than protection.
  • File permissions are policies on the storage.objects table, not a bucket setting. Locking down your database tables does nothing for your files.
  • A signed URL is a bearer token: whoever holds it can use it until it expires, and it is signed with a key you cannot rotate yourself.
  • A cached signed-URL response can outlive its token, so deleting the object is the only dependable way to cut off access.
  • One select policy covers both downloading a file and listing a folder, which is how users end up able to enumerate a bucket.

When should a bucket be public, and when private?

Public when the file is meant for everyone who can see the page it appears on, private for anything that belongs to one account. Supabase's bucket documentation is direct about what public means: access control is bypassed for retrieving and serving files, so anyone with the URL gets the file, with no session and no token.

Profile pictures, marketing images and blog media are reasonable public-bucket content. Invoices, contracts, identity documents, CSV exports and anything a customer uploaded about their own business are not.

Behaviour Public bucket Private bucket
Who can read a file Anyone with the URL Whoever a policy allows
How files are served Permanent /object/public/ URL Signed URL or authenticated request
CDN caching One cache entry per file One cache entry per token
Cutting off access Delete or move the file Delete the file; expiry alone is not enough
Best for Avatars, logos, public media Documents, exports, per-account files

What a public bucket still protects

Uploads, deletes, moves and copies. Public changes reads only, so your insert and delete policies still run, and a public bucket with no insert policy still rejects uploads from the browser.

That split catches teams out in both directions. Making a bucket public does not open it for writes, and making it private later does not secure URLs that are already in circulation.

Why a random filename is not access control

Public object URLs leak the way all URLs leak: into screenshots, support tickets, referrer headers, shared documents and the occasional search index. Once one is out, the file is out, and the only remedy is to move or delete it.

The useful question for each bucket is what happens if one of its URLs ends up in a public post. If the answer is "nothing much", public is fine. If you are also still deciding where files should live and what to check on the way in, our guide to secure file uploads, limits and storage costs covers the upload side of the same problem.

How do Supabase Storage policies work?

Every file has a row in the storage.objects table, and Storage checks row-level security policies on that table for each operation. Supabase's Storage access control guide notes that uploads are refused outright until a policy exists, so the usual cause of a failing upload is a missing policy rather than a bucket setting.

Two columns do most of the work. bucket_id scopes a policy to one bucket, and name holds the object's full path. The helper function storage.foldername(name) returns that path as an array, so element one is the top-level folder.

The convention that holds up is one folder per user, named with their user id, with the path chosen by your code rather than by the browser.

create policy "Users upload into their 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 "Users read their own files"
on storage.objects for select to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

Both clauses matter. with check decides what may be written, and using decides which existing rows may be read or changed, which is why an upload rule and a download rule are two separate statements.

Which policies does each operation need?

  • Upload: an insert policy on storage.objects.
  • Overwrite with upsert: insert plus select and update.
  • Download from a private bucket, or sign a URL: select.
  • Delete: delete, scoped the same way as the read rule.

Write them one per operation rather than as a single for all policy. A catch-all rule hides which clause was meant to govern uploads and which was meant to govern reads, and it is the first thing to get too loose when someone asks an AI builder to fix a failing request.

Stop users from listing the whole bucket

One SQL privilege is used by more than one Storage action: downloading an object and listing a folder both go through select. A read policy written for downloads therefore also lets the client enumerate every path it matches.

Supabase provides operation-aware helpers for this, so a listing rule can be narrower than a download rule.

create policy "Users list only their own objects"
on storage.objects for select to authenticated
using (
  storage.allow_only_operation('object.list')
  and owner_id = (select auth.uid()::text)
);

This matters most where the filenames are themselves information. A list of paths like exports/acme-corp-q1.csv tells a competitor who your customers are, even if every download is refused.

How long should a signed URL last?

Long enough for the browser to fetch the file and no longer, which usually means seconds or minutes. A signed URL is a temporary address that authorises access to one object, and createSignedUrl takes its lifetime in seconds.

const { data, error } = await supabase.storage
  .from('documents')
  .createSignedUrl(`${userId}/contract.pdf`, 60)

Sixty seconds is plenty for an image the page renders on load. A download link a user clicks from an email needs minutes, not hours. A document left open all afternoon needs a fresh URL on refresh rather than a longer expiry.

Keep signed URLs out of logs, analytics events and anything that forwards a full URL to a third party. They are credentials in URL form.

Expiry is not revocation

Storage signs these URLs with a dedicated internal key, separate from your Auth JWT signing key. Supabase's documentation on serving assets from Storage states that rotating, disabling or switching auth keys does not affect URLs that are already signed, and that revoking them means contacting Supabase support.

Treat the object, not the token, as the thing you control. If a file has to stop being reachable now, delete it or move it to a new path.

Signed URLs and the CDN cache

Smart CDN caching is enabled from the Pro plan up, and it treats every unique ?token= value as its own cache key. Two consequences follow, both set out in Supabase's Smart CDN guide.

A new signed URL on every page view means a cache miss on every page view, with the origin serving everything. Re-using one URL for the life of its token is what makes caching work at all.

And a cached response can keep being served for a URL whose token has already expired, because cache duration and token expiry are independent. Deleting the object invalidates every cached entry for it, though that change can take up to a minute to propagate.

So if a file has no real per-account restriction, a public bucket caches better and removes the signing work altogether. Choose private because the content needs it, not by reflex.

Supabase Storage security settings to check before launch

Six settings account for most of the distance between a prototype and a bucket you would let strangers upload to.

  1. Bucket visibility. Open the Storage dashboard and read the public flag on every bucket, including the ones an AI builder created for you.
  2. Global file size limit. Free projects cap a single file at 50 MB, while Pro and Team allow up to 500 GB, per the Storage limits documentation. Set the global ceiling to the largest file your product genuinely accepts.
  3. Per-bucket restrictions. Give each bucket a smaller size limit and an allowed MIME type list, so a misconfigured client cannot get past the checks in your API.
  4. Path convention. Generate keys on the server under the owner's user id, and keep customer names out of paths, since every path shows up in logs.
  5. Service key placement. The service key bypasses storage policies completely. It belongs on a server you control, never in front-end code.
  6. Image transformations. Available on Pro and above, and billable per use. Switch the feature off if you are not using it.

How do you test a bucket from outside your app?

Call the Storage API directly with your publishable key, because your own front end is the one client that behaves. Two checks find most real problems.

First, open a public object URL in a private window while signed out. If the file loads, that bucket is public, whatever your interface suggests.

Second, ask the API to list a bucket with no user signed in:

curl -X POST "https://YOUR_PROJECT_REF.supabase.co/storage/v1/object/list/documents" \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Authorization: Bearer YOUR_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"","limit":100}'

An empty array or an error means the bucket is closed to signed-out visitors. A list of filenames means anyone on the internet can read your storage layout. Then repeat the request with a second account's token, asking for the first account's folder, which is the test that catches a policy matching on the wrong column.

Storage is one item on a longer pre-launch list, and our launch checklist for AI-built apps covers the rest.

Common mistakes with Supabase Storage

  • Making a bucket public to fix a broken image, then never changing it back.
  • Letting the browser choose the storage path, so a user can write into someone else's folder.
  • Signing URLs that last a week because a one-minute URL expired during testing.
  • Assuming an expired token makes a file unreachable, when a cached response may still be served.
  • Writing one broad select policy, then finding the client can list every object it matches.
  • Reaching for the service key on the client to make permission errors go away.

Frequently asked questions

Is a public bucket safe for profile pictures?

Usually, if the picture is already visible to other users of your product. The risk is not the image but what sits beside it in the same bucket, so keep public buckets for genuinely public content and never mix documents in. Uploads and deletes still need policies either way.

Can I make a public bucket private later?

Yes, and the files stop being reachable through their public URLs as soon as you do. Anything that embedded those URLs needs to switch to signed URLs first, otherwise images in your own app break. Copies of those links held elsewhere also stop working, which is the point.

Do I need storage policies if every upload goes through my backend?

You need them unless no client ever holds a publishable key with storage access. While the Storage API is reachable with that key, the browser route exists whether or not your app uses it, and a feature added later by an AI builder may well start using it.

Why does my upload fail with a row-level security error?

Almost always a missing or mismatched insert policy. Check the bucket name in the policy, confirm the path your code generates starts with the folder the policy expects, and confirm the request is authenticated, because a policy limited to the authenticated role never matches an anonymous call.

Does signing URLs slow the app down?

Each signed URL is an API call, which is fine for one document and expensive for a gallery of 200 thumbnails. Sign in batches with createSignedUrls, re-use each URL for the life of its token, and keep genuinely public assets in a public bucket.

Before your first real upload

Go bucket by bucket and decide whether the content is public or per-account. Write the policies for the private ones, generate paths on the server, keep expiry short, and remember that deleting the object is the only reliable kill switch. Then run the list request against your own project and see what a stranger sees.

If you would rather have senior engineers close these gaps for you, AlgoSmiths works on fixed-price projects with a working demo every week. Our backend and API engineering work is where storage, policies and the services around them get built.

Posted In:
Software & SaaS Solutions

Add Comment Your email address will not be published