Supabase

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.

Aarav Sharma

Aarav Sharma

August 28, 202617 min read
Share
Diagram of Supabase Storage architecture showing buckets, RLS policies, and signed URLs for secure file uploads

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-key

Use 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:

PieceWhat it is
BucketsTop-level containers (like super folders)
FoldersOptional 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 typeUsed forTypical examples
Files bucketsGeneral file storageImages, PDFs, videos, documents
Analytics bucketsData lake / Iceberg tablesLogs, analytics data, pipelines
Vector bucketsEmbeddings + similarity searchAI 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:

AspectBehavior
Read accessAnyone with the URL can view/download the file
CDNStrong cache performance
Upload / delete / moveStill controlled by RLS policies
Best forAvatars, 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:

AspectBehavior
Read accessBlocked unless user passes RLS or uses a signed URL
DefaultBuckets are private by default
Best forInvoices, 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 actionSQL operationNotes
Upload a fileINSERT policyRequired for every upload
Download or generate a signed URLSELECT policyRequired for private buckets and file listing
Overwrite a file (upsert: true)UPDATE + SELECT policiesNeeded when replacing an existing file
Delete a fileDELETE policyRequired 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

MethodConditionBehaviorGood for
Public URL (getPublicUrl)File lives in a public bucketLink never expires; anyone with the URL can read itProfile photos, logos, static assets
Signed URL (createSignedUrl)File lives in a private bucketLink expires after N secondsInvoices, KYC docs, internal reports
Authenticated download (download())User is logged in, request sends their JWTRLS checks if they're allowed to read the fileAPI 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-key

Initialize 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;
}
ErrorLikely cause
403 / policyMissing or wrong INSERT policy
403 after "success"Missing SELECT policy
File too largeBucket or project file limit
Invalid MIMEallowedMimeTypes 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

FeatureSupabase StorageFirebase Storage
BackendPostgreSQL + Storage APIFirebase / Google Cloud
Access controlRLS on storage.objectsFirebase Security Rules
Auth integrationSupabase Auth (JWT)Firebase Auth
Public / private filesBucket-level + RLSRules-based
Signed URLsBuilt-in (createSignedUrl)Built-in
Open sourceStorage API is open sourceNo
Best fitApps already on SupabaseApps already on Firebase

Supabase Storage vs AWS S3 (direct)

FeatureSupabase StorageAWS S3
SetupBucket + policies in Supabase dashboardIAM, buckets, CORS, policies
PermissionsPostgres RLS policiesIAM roles and bucket policies
Auth tie-inNative with Supabase AuthCustom (Cognito or your own)
CDNIncludedCloudFront (separate setup)
ControlLess infra to manageFull control, more configuration
Best fitSupabase-backed appsLarge 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

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.

Related articles

Let's Collaborate

Tell us about your project and we'll come back with a plan, a timeline, and a quote.

Project Type

Budget

Task Message

Your Contacts