Supabase Storage is an S3-compatible file store organised into buckets, where access is controlled by Postgres row level security policies on a storage.objects table, files are served through a CDN, and private files are shared with time-limited signed URLs.
The design is clever because the file store reuses the auth and policy model you already set up for your database. It is also where most people get stuck, because the first upload fails with an unhelpful policy error and the fix is a SQL statement, not a setting. Here is how buckets, policies, signed URLs and transforms actually behave, and where file storage sits once the app is deployed.
Table of contents
- Buckets, and the public versus private decision
- RLS policies on storage.objects
- Uploading, downloading and signed URLs
- Image transformations, CDN and caching
- Limits, quotas and the things that surprise people
- How file storage fits a deployed app
- How this fits the rest of the stack
- FAQ
Buckets, and the public versus private decision
Everything in Storage lives in a bucket. A bucket is a named container with a few properties: whether it is public, an optional maximum file size, and an optional list of allowed MIME types. Files inside are addressed by path, and folders are just prefixes in that path, exactly as in S3.
The public versus private choice is the most important decision. A public bucket serves files to anyone who has the URL, with no authorization check on reads, and is served with aggressive CDN caching. Use it for content that is meant to be seen: product photos, blog images, avatars people expect to be visible. A private bucket requires authorization for every read, either through the signed-in user’s session and a policy, or through a signed URL. Use it for invoices, private documents, user uploads that belong to a person.
Do not put private material in a public bucket and rely on unguessable filenames. URLs leak through referrers, logs, screenshots and shared links, and a public URL is valid forever. Deciding early matters because switching a bucket from public to private later means every URL you already handed out stops working.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('avatars', 'avatars', true, 2097152, array['image/png', 'image/jpeg', 'image/webp']);
The Supabase Storage docs also describe analytics and vector bucket types for data-lake and embedding workloads; for ordinary app files you want the standard file buckets described here.
RLS policies on storage.objects
Each file has a row in the storage.objects table, and access is governed by row level security on that table. If you create a private bucket and upload with a user session before adding any policy, the upload fails, because RLS denies by default. This is the single most common first-hour problem.
Policies are written in SQL against the object row. Helper functions in the storage schema let you inspect the path: storage.foldername(name) splits it into folder segments, so a convention like userId/filename lets a policy check that the first folder equals the signed-in user’s id.
-- users can upload only inside their own folder
create policy "users upload to own folder"
on storage.objects for insert to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- users can read only their own files
create policy "users read own files"
on storage.objects for select to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = auth.uid()::text
);
Policies are separate for select, insert, update and delete. Updating a file (upsert) needs select, insert and update policies together, which trips people who wrote only an insert policy. Test them with a real user token, not the service role key, because the service role bypasses RLS entirely and hides your mistakes.
A related database point is worth having in your head: this is your Postgres, with your policies, so mistakes here are mistakes in your data access model. We covered the hosting side in Supabase hosting.
Uploading, downloading and signed URLs
From the JavaScript client, uploading is a single call. The path you choose is what the policy inspects, so design it deliberately.
const { data, error } = await supabase.storage
.from('documents')
.upload(`${user.id}/invoice-2026-09.pdf`, file, {
cacheControl: '3600',
upsert: false,
});
Reading a public file is a URL construction, no request needed. Reading a private file needs either a request authorised as the user, or a signed URL. A signed URL embeds a token that grants access to one object for a limited number of seconds, without needing the caller to be signed in. It is the right tool for emailing a link, embedding a private image in a page, or handing a file to another service.
const { data } = await supabase.storage
.from('documents')
.createSignedUrl(`${user.id}/invoice-2026-09.pdf`, 60 * 10); // valid for 10 minutes
console.log(data.signedUrl);
Keep the expiry short and generate the URL on demand, in a request handler, not at build time. There is also a signed upload URL, which lets a client upload one specific path without holding a session, useful for direct-to-storage uploads from a browser after your backend checks permission. For large files, use resumable uploads through the TUS protocol so a dropped connection does not restart a 500 MB upload from zero.
Image transformations, CDN and caching
Supabase serves files through a CDN, and public files are cached at the edge. That is why a replaced file can appear stale for a while: the URL is the same, but the cached copy is not. Set cacheControl deliberately at upload, and prefer new filenames (or a version query string) over overwriting a file under the same path when it matters that everyone sees the change.
Image transformations resize, compress and convert images on the fly through URL parameters or the client library, so you store one original and request a thumbnail at the size you need. The feature has been available on paid plans, and transformed images are cached, so check the current plan details before you design around it.
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('ada.png', {
transform: { width: 128, height: 128, resize: 'cover', quality: 80 },
});
Two practical habits. Always request explicit dimensions for images shown in a list, because serving a 4000-pixel original into a 64-pixel avatar slot is the most common performance mistake with any object store. And set alt text and width and height in your markup, so layout does not jump while the image loads. For general CDN behaviour and alternatives, see our free CDN providers overview.
Limits, quotas and the things that surprise people
Storage has three kinds of limits: total storage per project, maximum file size, and bandwidth (egress). The free plan has small allowances for all three, and the maximum single file size on the free plan is modest. Uploading a large video and getting a size error is a rite of passage; the fix is either a higher plan or a different home for large media.
A bucket-level file size limit and allowed MIME types let you enforce rules server-side. Do that, because client-side checks are advisory. Check the current numbers on Supabase’s pricing and limits pages, since they change and any figure in a blog post goes stale. We tracked the free plan separately in Supabase free tier.
- Project pausing on the free plan affects storage availability as well as the database.
- Deleting rows in storage.objects with SQL does not necessarily delete the underlying file; use the API.
- Public bucket reads are not subject to your policies, only private ones are.
- The service role key bypasses everything and must never reach a browser.
Backups deserve a thought. Database backups do not automatically include your files, so an export strategy for objects matters if the files are the business. Because Storage is S3-compatible, standard tools can copy a bucket to another store, which is also your exit route if you outgrow it. See S3-compatible storage for the wider landscape.
How file storage fits a deployed app
Most applications need files in two shapes: user-generated uploads and static assets. Uploads are dynamic, belong to users, and need access control; static assets are build output. Conflating them creates problems. Keep uploads in an object store with policies, and keep build output with the deployment.
Think about the boundary between your web service and the store. Your service should generate signed URLs and check permissions; the browser should upload and download directly to storage where it can, so file bytes do not flow through your server. Keep credentials as environment variables, never in the repository, and give the service only the keys it needs.
There is also a simpler case: a service that needs local disk for temporary files, caches or an SQLite file. That is persistent storage attached to the service rather than an object store, and the distinction matters. Ephemeral disk vanishes on redeploy; attached persistent storage does not. The storage docs on RunxBuild describe that volume, which suits generated files, caches and uploads that never need a public URL.
A sensible split for a typical app: the database holds relational data and file metadata (path, owner, size, type), an object store holds the file bytes, and the web service is the thin layer that enforces who can do what.
How this fits the rest of the stack
Storage in Supabase is well designed if you accept its model: buckets, RLS, signed URLs, a CDN and transforms, all tied to your Postgres. Where it lives relative to your app is the next question. If the app itself needs a home, with a web service, a managed Postgres or MySQL and attached persistent storage, the RunxBuild hosting calculator shows how those pieces add up together. Deploy the service from the RunxBuild dashboard and keep your keys in environment variables. Reference: Storage on RunxBuild. Before you go live, run a short checklist: confirm each bucket is public or private on purpose, test every policy with a real user token, set file size and MIME limits, decide how you will back up objects, and check that no service role key appears anywhere in client code or in the repository history.
Useful related references:
FAQ
What is Supabase Storage?
Supabase Storage is an S3-compatible object store organised into buckets, integrated with your Postgres database and auth. Access is enforced by row level security policies on the storage.objects table, files are served through a CDN, and private files can be shared with time-limited signed URLs.
Why does my Supabase upload fail with a policy error?
Row level security denies by default. A private bucket needs an insert policy for the authenticated role, and upserts also need select and update policies. Test using a real user token rather than the service role key, which bypasses RLS and hides missing policies.
What is the difference between public and private buckets?
Public buckets serve files to anyone with the URL and are cached on the CDN, ideal for images meant to be seen. Private buckets require authorization for every read, through a user session and policies or a signed URL. Do not put sensitive files in a public bucket.
How do signed URLs work in Supabase?
A signed URL includes a token that grants access to one object for a set number of seconds, without the caller being signed in. You generate it with createSignedUrl on the server, keep expiry short, and hand it to a user or service. There are also signed upload URLs for direct uploads.
Is Supabase Storage free?
The free plan includes a small amount of storage and bandwidth, with a modest maximum file size, and image transformations are a paid feature. Limits change, so check the current pricing page. Free projects can also be paused after inactivity, which affects file availability.