Supabase Storage: Secure File Uploads, Buckets, Policies, and Signed URLs
Set up Supabase Storage for production: private buckets, RLS policies on storage.objects, secure uploads, and signed URLs for private files.

Supabase Storage is Supabase's built-in object storage for images, documents, and other files, with access control powered by PostgreSQL Row Level Security (RLS). Most apps need file uploads, but rolling your own storage stack means handling S3, auth, permissions, and secure download links — work that slows shipping and creates security gaps. This guide shows how to set up Supabase Storage the right way: private buckets, RLS policies, secure uploads, and signed URLs for time-limited access. You'll learn when to use public vs private buckets, how to write storage policies, upload files from your app, and serve private files without exposing them publicly.
What Is Supabase Storage?
Supabase Storage is a scalable object storage service for files of any size, with fine-grained access control through PostgreSQL Row Level Security and CDN-backed delivery. Files live in buckets that can be public or private; uploads stay blocked until you add RLS policies on storage.objects. For private files, use signed URLs to grant time-limited download access without making the bucket public. Secure uploads combine the right bucket type, storage policies, authenticated uploads, and short-lived signed URLs.
Key features:
- Multi-protocol support — S3-compatible storage, RESTful API, and TUS resumable uploads for large files
- Global CDN — Serve assets with low latency from edge locations worldwide
- Image optimization — Resize, compress, and transform images on the fly
- Fine-grained access control — Manage file permissions with Row Level Security and custom policies on storage.objects
- Multiple bucket types — Separate buckets for different use cases (e.g. public avatars vs private user documents)
What Problem Are We Facing When We Upload a File?
Most applications need file storage — profile photos, invoices, contracts, ID documents, attachments. The hard part is not storing the file; it is doing it securely without building and maintaining a custom file server.
- Object storage setup (S3 or similar), bucket configuration, CORS, and upload endpoints
- Authentication and authorization — deciding who can upload, read, update, or delete each file
- Access control mistakes — public buckets or weak rules that expose private user files to anyone with a URL
- Secure downloads — private files cannot use a permanent public link; you need time-limited signed URLs or authenticated requests
- Validation and limits — file type, file size, malware risk, and abuse from unauthenticated uploads
- Production edge cases — failed uploads, overwrites (upsert), RLS policy errors, and expired signed links
Many teams either over-engineer a custom storage layer or under-engineer it: they get uploads working quickly, then discover a document bucket was public, policies were too permissive, or the frontend was given keys it should never have.
That is the gap this guide addresses: secure Supabase Storage — the right bucket model, RLS policies, upload flow, and signed URLs — without exposing user data or rebuilding storage from scratch.
How Does Supabase Storage Solve That Problem?
Supabase Storage fixes the "secure uploads without a custom file server" problem by keeping files and permissions in one place. You store binaries in buckets, store metadata in Postgres, and control access with the same RLS patterns you already use on tables.
You don't need a separate auth layer for files. If a user is logged in, their JWT is checked against policies on storage.objects. No policy match → upload or download fails. That default-deny model is what stops the "public bucket by accident" issue.
The Basic Flow
Most apps only need this pattern:
- Create a private bucket for anything user-specific
- Add RLS policies for upload (INSERT) and read (SELECT)
- Upload to a path like {userId}/filename.pdf
- Serve private files with signed URLs, not permanent public links
Public buckets are fine for avatars and marketing assets. Everything else — invoices, contracts, ID uploads — belongs in a private bucket.
Request flow: User (browser / mobile) → Your app (Next.js, React, etc.) → Supabase Auth (JWT) → Supabase Storage API → RLS check on storage.objects → File saved → private bucket + CDN on read.
Prerequisites
Required:
- A Supabase project (supabase.com)
- Node.js 18+ (or whatever your app already runs on)
- A frontend or backend app — Next.js, React, or plain Node is fine
- @supabase/supabase-js installed
- Basic JavaScript — async/await, env vars
Helpful but not mandatory:
- Supabase Auth set up (Storage policies often use authenticated and auth.uid())
- Rough idea of RLS — USING vs WITH CHECK on policies
- A test file ready (small PDF or image) for upload tests
Environment variables (standard setup):
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyUse the anon key in client-side uploads with RLS — not the service role key. Service role bypasses all policies; keep it server-side only.
How Does Supabase Storage Work?
Supabase Storage has three core pieces:
| Piece | What it is |
|---|---|
| Buckets | Top-level containers (like super folders) |
| Folders | Optional paths inside a bucket (user-id/invoices/) |
| Objects (files) | The actual files stored in a bucket |
What Is a Bucket?
A bucket is a named container for files. Think of it as a separate storage area with its own security rules.
Why use buckets?
- Split files by purpose (avatars vs invoices vs admin assets)
- Apply different rules per bucket (public read vs private only)
- Set per-bucket limits (max size, allowed MIME types)
Example layout:
avatars/ → public bucket (profile photos)
user-documents/ → private bucket (invoices, KYC)
app-assets/ → public bucket (logos, marketing images)Rule of thumb: one bucket per access pattern, not one bucket per user. Use folders inside the bucket for per-user paths.
How Many Bucket Types Does Supabase Have?
Supabase Storage is not a single "files only" product. There are 3 bucket types:
| Bucket type | Used for | Typical examples |
|---|---|---|
| Files buckets | General file storage | Images, PDFs, videos, documents |
| Analytics buckets | Data lake / Iceberg tables | Logs, analytics data, pipelines |
| Vector buckets | Embeddings + similarity search | AI search, RAG, semantic matching |
For secure file uploads, policies, and signed URLs, this blog focuses on Files buckets — the type used for normal app uploads.
Files Buckets: Public vs Private
Within Files buckets, there are 2 access models:
1. Public bucket:
| Aspect | Behavior |
|---|---|
| Read access | Anyone with the URL can view/download the file |
| CDN | Strong cache performance |
| Upload / delete / move | Still controlled by RLS policies |
| Best for | Avatars, marketing images, public assets |
// Public URL — no auth needed to read
const { data } = supabase.storage
.from('avatars')
.getPublicUrl('user-123/profile.png');2. Private bucket:
| Aspect | Behavior |
|---|---|
| Read access | Blocked unless user passes RLS or uses a signed URL |
| Default | Buckets are private by default |
| Best for | Invoices, contracts, ID docs, internal files |
Two ways to read private files:
- Authenticated download — user JWT + RLS SELECT policy
- Signed URL — time-limited link via createSignedUrl()
// Signed URL — valid for 1 hour
const { data } = await supabase.storage
.from('user-documents')
.createSignedUrl('user-123/invoice.pdf', 3600);How Can We Create a Bucket?
You can create a bucket using the Supabase Dashboard. Since storage is interoperable with your Postgres database, you can also use SQL or our client libraries.
Here we create a bucket called "avatars":
Using Supabase Dashboard:
- Go to the Storage page in the Dashboard.
- Click New Bucket and enter a name for the bucket.
- Click Create Bucket.
Using Javascript:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!)
// Use the JS library to create a bucket.
const { data, error } = await supabase.storage.createBucket('avatars', {
public: true, // default: false
})Restricting Uploads
When creating a bucket you can add additional configurations to restrict the type or size of files you want this bucket to contain.
For example, imagine you want to allow your users to upload only images to the avatars bucket and the size must not be greater than 1MB. You can achieve the following by providing allowedMimeTypes and maxFileSize:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!)
// Use the JS library to create a bucket.
const { data, error } = await supabase.storage.createBucket('avatars', {
public: true,
allowedMimeTypes: ['image/*'],
fileSizeLimit: '1MB',
})What Are Storage Policies?
A storage policy is a security rule that controls who can upload, read, update, or delete files in Supabase Storage. Policies are applied to the storage.objects table in Postgres using Row Level Security (RLS). Every file you upload is stored as a row in that table, so the policy decides whether that action is allowed or blocked.
By default, Supabase blocks uploads until you add policies. Nothing is writable until you explicitly allow it — that avoids accidental open upload access.
Why use policies?
- Control who can upload files to a bucket
- Control who can download or view private files
- Restrict uploads to a specific user folder (e.g. {userId}/invoice.pdf)
- Allow or block overwrite and delete actions
- Keep private files protected even if someone guesses the file path
Without policies, uploads fail — or worse, you add an overly broad rule and expose user files.
Which policy do you need?
Match the Storage action to the SQL operation:
| Storage action | SQL operation | Notes |
|---|---|---|
| Upload a file | INSERT policy | Required for every upload |
| Download or generate a signed URL | SELECT policy | Required for private buckets and file listing |
| Overwrite a file (upsert: true) | UPDATE + SELECT policies | Needed when replacing an existing file |
| Delete a file | DELETE policy | Required if your app has delete functionality |
Important notes:
- Public buckets let anyone read files via URL, but upload, update, and delete still need policies.
- Upload can fail even when INSERT exists if SELECT is missing — Storage returns the new row after insert.
- Using upsert: true without UPDATE and SELECT policies will fail.
For example, one can start with the following INSERT policy:
create policy "policy_name"
ON storage.objects
for insert with check (
true
);and modify it to only allow authenticated users to upload assets to a specific bucket by changing it to:
What Are Signed URLs?
A signed URL is a temporary link to a private file. It works for a fixed time — 15 minutes, an hour, whatever you set — then it stops working.
Private buckets don't give you a permanent public link. That's the point. If you need someone to view or download a file without making the whole bucket public, you generate a signed URL.
The URL itself includes a token. Anyone who has it can open the file until it expires. So treat signed URLs like short-lived keys, not permanent share links.
Why use signed URLs?
- Let users preview or download their own private files in the browser
- Share a file temporarily — email, support ticket, in-app viewer
- Avoid exposing a public bucket for sensitive documents
- Work in mobile apps and SPAs where you can't attach a JWT to every <img> or PDF viewer
- Keep the bucket private while still allowing controlled access
For avatars and marketing images, a public URL is usually enough. For invoices, contracts, ID scans — signed URLs are the safer default.
When to use what
| Method | Condition | Behavior | Good for |
|---|---|---|---|
| Public URL (getPublicUrl) | File lives in a public bucket | Link never expires; anyone with the URL can read it | Profile photos, logos, static assets |
| Signed URL (createSignedUrl) | File lives in a private bucket | Link expires after N seconds | Invoices, KYC docs, internal reports |
| Authenticated download (download()) | User is logged in, request sends their JWT | RLS checks if they're allowed to read the file | API routes, server-side fetch, when you don't need a shareable link |
Pick based on who needs access and how long, not convenience.
Step-by-Step Implementation
Step 1: Create a Supabase project and install the client
If you already have a project, skip to Step 2.
- Create a project at supabase.com
- Install the JS client in your app
- Add env vars:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyInitialize the client:
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);Step 2: Create a private bucket
Create user-documents as a private bucket with upload limits.
Option A — Dashboard
- Go to Storage → New bucket
- Name: user-documents
- Public bucket: OFF
- (Optional) Set file size limit and allowed MIME types
Option B — Code
const { data, error } = await supabase.storage.createBucket('user-documents', {
public: false,
allowedMimeTypes: ['application/pdf', 'image/*'],
fileSizeLimit: '5MB',
});
if (error) console.error(error);What you get:
- A separate container for user files
- No public read access by default
- Rejected uploads if file type or size doesn't match bucket rules
Optional — public bucket for avatars
If you also need profile photos:
await supabase.storage.createBucket('avatars', {
public: true,
allowedMimeTypes: ['image/*'],
fileSizeLimit: '1MB',
});Public bucket = permanent URL for reads. Upload still needs policies (Step 3).
Step 3: Add storage policies (RLS)
Without policies, uploads fail. Add rules on storage.objects.
Dashboard: Storage → Policies → objects → New policy
-- Upload: only into your own folder
create policy "Users upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'user-documents'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
-- Read: required for download + signed URLs
create policy "Users read own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'user-documents'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
-- Delete (optional)
create policy "Users delete own files"
on storage.objects for delete
to authenticated
using (
bucket_id = 'user-documents'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);Step 4: Upload a file securely
Get the logged-in user's ID, then upload into their folder.
async function uploadInvoice(file) {
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) throw new Error('User not logged in');
const filePath = `${user.id}/invoice.pdf`;
const { data, error } = await supabase.storage
.from('user-documents')
.upload(filePath, file, {
contentType: file.type,
cacheControl: '3600',
upsert: false,
});
if (error) throw error;
return data.path;
}| Error | Likely cause |
|---|---|
| 403 / policy | Missing or wrong INSERT policy |
| 403 after "success" | Missing SELECT policy |
| File too large | Bucket or project file limit |
| Invalid MIME | allowedMimeTypes on bucket |
Step 5: Generate a signed URL (private download)
Private files aren't served from a permanent public link. Create a short-lived URL when the user opens or downloads the file.
async function getInvoiceSignedUrl() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('User not logged in');
const filePath = `${user.id}/invoice.pdf`;
const { data, error } = await supabase.storage
.from('user-documents')
.createSignedUrl(filePath, 900); // 15 minutes
if (error) throw error;
return data.signedUrl;
}Use the URL:
const url = await getInvoiceSignedUrl();
window.open(url); // or: <a href={url}>Download</a>Only generate this after you know the user should access that file. RLS + folder path should already restrict who can call createSignedUrl.
Step 6: Public bucket URL (when you need it)
For avatars (public bucket), skip signed URLs for reads:
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(`${user.id}/profile.png`);
console.log(data.publicUrl);Step 7: Test the full flow
Run through this checklist with two test users (User A and User B):
Bucket
- user-documents exists and is private
- Upload limits (size / MIME) behave as expected
Policies
- User A uploads to user-a-id/invoice.pdf → success
- User A uploads to user-b-id/invoice.pdf → fail
- User B cannot read User A's file
Upload
- Logged-out upload → fail
- Valid PDF under size limit → success
Signed URL
- User A gets signed URL for own file → opens in browser
- URL stops working after expiry
- User A cannot get signed URL for User B's path (403 or policy block)
Public bucket (if used)
- getPublicUrl works without auth
- User cannot upload into another user's avatar folder
Common Problems / Errors
Upload fails with 403 or "new row violates row-level security policy"
Possible causes:
- No INSERT policy on storage.objects
- Policy bucket_id doesn't match your bucket name
- Upload path doesn't match the folder rule (e.g. not {userId}/file.pdf)
- User isn't logged in (authenticated policies require a valid JWT)
- Typo in policy — wrong bucket name or folder check
Solution: Add an INSERT policy scoped to your bucket and user folder. Upload using ${user.id}/filename.ext. Confirm the user is signed in before calling upload(). Test in the SQL Editor that the bucket name in the policy matches exactly.
Upload seems to work but returns an error
Possible causes:
- INSERT policy exists but SELECT is missing
- Storage runs RETURNING after insert — needs read permission on the new row
Solution: Add a SELECT policy for the same bucket and folder pattern as your upload rule.
upsert: true fails even when upload works
Possible causes:
- Missing UPDATE policy
- Missing SELECT policy (needed for overwrite flow)
Solution: Add both UPDATE and SELECT policies for the same user/folder scope, or set upsert: false if you don't need overwrites.
Signed URL returns 403 or null
Possible causes:
- No SELECT policy for that user/path
- Wrong file path (different from upload path)
- User requesting a file outside their folder
- Bucket is private but you're using getPublicUrl() instead of createSignedUrl()
Solution: Use createSignedUrl() for private buckets. Match the exact path used on upload. Ensure SELECT policy allows that user to read that object. Don't use public URL helpers on private files.
File too large or invalid type rejected
Possible causes:
- Bucket fileSizeLimit or allowedMimeTypes too strict
- Global project file size limit lower than bucket limit
- Wrong contentType on upload
Solution: Check Storage → Settings for global limits, then bucket settings. Set allowedMimeTypes and fileSizeLimit when creating the bucket. Pass correct contentType in the upload options.
Best Practices
- Default to private buckets for user uploads (invoices, ID docs, contracts). Use public buckets only for assets that are OK on the open web (avatars, logos).
- Use one bucket per access pattern, not one bucket per user. Per-user isolation goes in the folder path ({userId}/...) and policies, not in creating dozens of buckets.
- Never put the service role key in the front end code. Use the anon key + RLS on the client. Service roles belong only on trusted servers (Edge Functions, API routes).
- Match upload path to policy. If the policy expects {userId}/file.ext, the app must upload to that exact structure — frontend validation alone isn't enough.
- Set bucket limits early — allowedMimeTypes, fileSizeLimit — so bad files are rejected at the bucket, not only in your UI.
- Keep signed URL expiry short. 15–60 minutes for in-app use; avoid multi-day links for sensitive files. Generate the URL after confirming the user should access the file.
Performance / Security Considerations
Performance
- Use public buckets for high-traffic static assets (logos, avatars) — they cache better on CDN.
- Use private buckets for user documents; generate signed URLs only when needed, not on every page load.
- For large files (6MB+), use resumable uploads (TUS) instead of standard upload.
- For images, use Supabase image transforms instead of storing multiple sizes yourself.
Security
- RLS is default deny — add explicit policies for every allowed action.
- Signed URLs = temporary access — confirm the user should see the file before calling createSignedUrl().
- Public bucket ≠ open upload — lock down INSERT even on public buckets.
- Enforce file type and size in bucket config and RLS; don't rely on frontend validation alone.
- Client uploads need a valid Auth JWT when using authenticated policies.
Production
- Keep env vars for keys; never commit secrets.
- Split buckets by purpose and stick to a path pattern like {userId}/ early.
- Watch 403s and failed uploads in logs — often policy or auth issues.
- After policy changes, test with two users before shipping.
Alternatives / Comparison
Supabase Storage isn't the only way to handle file uploads. Here's how it compares to common options for a typical web or mobile app.
Supabase Storage vs Firebase Storage
| Feature | Supabase Storage | Firebase Storage |
|---|---|---|
| Backend | PostgreSQL + Storage API | Firebase / Google Cloud |
| Access control | RLS on storage.objects | Firebase Security Rules |
| Auth integration | Supabase Auth (JWT) | Firebase Auth |
| Public / private files | Bucket-level + RLS | Rules-based |
| Signed URLs | Built-in (createSignedUrl) | Built-in |
| Open source | Storage API is open source | No |
| Best fit | Apps already on Supabase | Apps already on Firebase |
Supabase Storage vs AWS S3 (direct)
| Feature | Supabase Storage | AWS S3 |
|---|---|---|
| Setup | Bucket + policies in Supabase dashboard | IAM, buckets, CORS, policies |
| Permissions | Postgres RLS policies | IAM roles and bucket policies |
| Auth tie-in | Native with Supabase Auth | Custom (Cognito or your own) |
| CDN | Included | CloudFront (separate setup) |
| Control | Less infra to manage | Full control, more configuration |
| Best fit | Supabase-backed apps | Large or custom AWS-native systems |
When Should You Use It?
Supabase Storage is a good choice when:
- You're already using Supabase for auth, database, or APIs — files stay in the same project and permission model
- You need per-user file access (invoices, documents, uploads) with RLS-style rules
- You want private buckets + signed URLs without building your own signing service
- Your team knows Postgres / SQL policies (or is willing to learn them once for Storage)
- You're building a Next.js, React, or mobile app and want a single SDK for auth + storage
- File needs are typical app storage — avatars, PDFs, images, attachments — not a full media CDN pipeline
FAQ
What is the difference between a public and private bucket in Supabase?
A public bucket lets anyone with the file URL read the file without authentication. A private bucket blocks public access — users need a valid JWT (with matching RLS policy) or a signed URL to download the file. Uploads still require policies in both cases.
Why do my Supabase Storage uploads fail with a 403 error?
Usually a missing or incorrect RLS policy. Uploads need an INSERT policy on storage.objects. If upload returns an error after insert, you may also need a SELECT policy. Check that the file path matches your policy (e.g. {userId}/filename.pdf) and that the user is logged in.
Do I need signed URLs for every file in Supabase Storage?
No. Signed URLs are for private buckets when you need temporary access — previews, downloads, email links. Public buckets use permanent public URLs via getPublicUrl(). Use signed URLs only when the file shouldn't be permanently public.
Can I upload files from the browser securely with Supabase?
Yes. Log the user in with Supabase Auth, use the anon key (not the service role key) in the client, and enforce access with RLS policies. Upload to a path like {userId}/file.ext and restrict policies to that user's folder. For stricter control, generate a signed upload URL on the server.
Is Supabase Storage the same as AWS S3?
Supabase Storage is S3-compatible object storage, but access is managed through Supabase's API and Postgres RLS — not IAM. You don't configure S3 directly unless you use Supabase's lower-level integrations. For most apps, you work with buckets, policies, and the Supabase JS client.
Conclusion
Secure file handling is easy to get wrong — public buckets, missing policies, or long-lived signed URLs can expose user data. Supabase Storage addresses this with buckets for organization, RLS policies on storage.objects for access control, and signed URLs for time-limited access to private files.
The pattern that works: private bucket for user files, per-user folder paths, INSERT and SELECT policies, upload from the app, signed URL when the user needs to view or download. Public buckets only for assets that are safe on the open web.

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


