Skip to content

Backend

Supabase Storage for Customer File Uploads — RLS-Gated Buckets and Image Transform Patterns

All articles
🪣 🔐 🖼️

RLS policies on buckets, image transform query params, tus-js-client resumable uploads

You've got users uploading business card photos for OCR, insurance claim evidence images, booking invoices. Every file needs to be private—visible only to the owner + their org. You could spin up S3, CloudFront, presigned URLs, and IAM policies. Or you could use Supabase Storage and let Postgres enforce who downloads what. For small-to-mid SaaS, the latter saves weeks.

Why Supabase Storage Over S3 + CloudFront

S3 is the safe choice because it's industry-standard. But it's also overkill for most SaaS apps: you'll need IAM roles, presigned URL lambdas, CloudFront cache invalidation, and separate billing. Supabase Storage is Postgres-first. Your RLS policies from your data tables extend directly to the file layer—same org_id checks, same JWT claims, zero new infrastructure.

The tradeoff: Supabase Storage is younger and less battle-hardened globally. For internal tools, SaaS under 1M files, and any product where your data already lives in Postgres, it's a no-brainer. For 10M+ assets or super-high concurrency, S3's edge network wins. But you're probably not there yet.

RLS Bucket Policies — Same Org, Same Rules

Create a bucket and attach RLS policies that read org_id from the JWT:

{`-- Create bucket
insert into storage.buckets (id, name, public)
values ('customer-uploads', 'customer-uploads', false);

-- RLS: users can upload to their own org path
create policy "users can upload to their org"
on storage.objects
for insert
with check (
  bucket_id = 'customer-uploads'
  and (storage.foldername(name))[1] = auth.jwt() ->> 'org_id'
  and auth.role() = 'authenticated'
);

-- RLS: users can read files from their org
create policy "users can read their org files"
on storage.objects
for select
using (
  bucket_id = 'customer-uploads'
  and (storage.foldername(name))[1] = auth.jwt() ->> 'org_id'
);

-- RLS: users can delete their own uploads
create policy "users can delete their own files"
on storage.objects
for delete
using (
  bucket_id = 'customer-uploads'
  and (storage.foldername(name))[1] = auth.jwt() ->> 'org_id'
  and owner_id = auth.uid()
);`}

The magic: (storage.foldername(name))[1] extracts the first folder in the path. Store files as org-123/invoice.pdf and the policy checks org-123 == user's org_id. Postgres enforces it. No Lambda. No presigned URL logic. Done.

Image Transform on Read — WebP + Resize

Supabase Storage supports transforms via query params on the public URL. If you're storing visit-evidence photos, generate thumbnails on-the-fly without resizing disk space:

{`// React component: fetch resized image
const imageUrl = supabase.storage
  .from('customer-uploads')
  .getPublicUrl('org-123/visit-photo.jpg', {
    transform: {
      width: 400,
      height: 300,
      resize: 'cover',
      format: 'webp'
    }
  }).data.publicUrl;

// Or use the raw URL with params (for Markdown, email templates):
// https://project.supabase.co/storage/v1/object/public/customer-uploads/org-123/visit-photo.jpg?width=400&format=webp`}

Supabase caches transforms at the CDN edge. First request computes; subsequent requests serve cached. On large files (2MB+ JPEG), you're looking at 5–8× smaller WebP output. For mobile-heavy SaaS, that's real bandwidth savings.

Resumable Uploads for Large Files — tus-js-client

Uploading a 50MB claim evidence video over a flakey connection is painful. Supabase Storage supports the TUS protocol—resumable uploads that pick up where they left off:

{`import * as tus from 'tus-js-client';

const startUpload = async (file: File, orgId: string) => {
  const uploader = new tus.Upload(file, {
    endpoint: \`\${SUPABASE_URL}/storage/v1/upload/resumable\`,
    headers: {
      authorization: \`Bearer \${session.access_token}\`,
    },
    metadata: {
      bucketName: 'customer-uploads',
      objectName: \`\${orgId}/\${file.name}\`,
    },
    onProgress: (bytesUploaded: number, bytesTotal: number) => {
      console.log(\`Progress: \${(bytesUploaded / bytesTotal) * 100}%\`);
    },
    onSuccess: () => {
      console.log('Upload complete');
    },
  });

  uploader.start();
};`}

The browser pauses the upload on network loss and resumes from the byte offset. For users on sketchy WiFi uploading insurance evidence, this is the difference between "works eventually" and "rage-quits your app".

The Catch: Cold Boots and CDN Edge

Supabase Storage uses an HTTP middleware tier that can be slow on first request, especially for transforms. The first 400×300 WebP generation might take 1–2s. Subsequent requests hit CDN cache, but you need to warm it. For high-concurrency scenarios, consider pre-generating common sizes or caching the URL on insert.

Also: RLS policies on storage objects are checked on every request. If your policy subqueries the team_members table, you'll get the same performance cliffs as database queries. Keep policies simple; store org_id in the path, not in metadata.

FAQs

Can I list files in a private bucket?

Yes, with RLS policies. list() respects the same policies as select. Users see only files they have access to. Works great for building a file browser inside your app.

What about direct browser uploads without a server?

Use signed URLs. Your backend generates a short-lived upload token, browser posts directly to Supabase, and the policy checks the token. Zero server load during upload. See Supabase docs for createSignedUploadUrl().

Can I encrypt files at rest?

Not natively via Supabase Storage. If you need client-side encryption, encrypt the file before upload, store the key in a separate secret, and decrypt on read. It's slower but bulletproof for sensitive docs.

How do I handle image EXIF data?

Supabase Storage strips EXIF by default on transforms. If you need metadata preserved, access the raw object. For uploads, consider a function that reads EXIF and stores it in a separate files table row.

Is there a file size limit?

Supabase Storage handles files up to 5GB. Larger files need special handling. For most SaaS, you're fine. For video, consider Cloudinary or Mux.

The Verdict

Supabase Storage is the secret weapon for SaaS that already chose Postgres. Your org isolation, your RLS logic, your JWT claims—they all flow through to files. No new auth system. No presigned URL Lambdas. Just file-bucket policies that read the same org_id you're using everywhere else. Image transforms on CDN are a freebie. Resumable uploads with tus make large files bulletproof. Cold boots on first transform are real, but caching solves it. For under 100M files and under 1PB storage, ship it and move on to something harder.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.