Switch Language
Toggle Theme

Supabase Storage in Practice: File Uploads, CDN, and Access Control

Last week, a reader asked me: “For user avatar uploads, should I use S3 or Cloudflare R2?”

I paused. Honestly, I’ve hit pitfalls with both in past projects. S3’s permission policies were a headache, and while R2 is cheaper, I had to build my own authentication system. Later, I migrated my projects to Supabase Storage—not because it’s a “silver bullet,” but if you’re already using Supabase’s Auth and database, Storage is incredibly smooth to work with.

In this article, I’ll break down Supabase Storage’s core mechanisms, three access control modes, pitfalls with large file uploads, CDN optimization tips, and a cost comparison with R2/S3. All code examples are ready to run.


1. Supabase Storage Core Architecture

Let’s clarify one thing first: Supabase Storage is built on AWS S3 under the hood.

But it wraps it in a thin layer—so thin that you can operate it directly with the JavaScript SDK without dealing with AWS’s complex credential system and IAM policies.

Automatic Integration with Auth

This is my favorite feature. You create a bucket in Supabase, then use the JWT token from supabase.auth.getUser() to control who can upload and download. No need to build a separate permission system.

// Automatically includes user identity during upload
const { data, error } = await supabase.storage
  .from('avatars')
  .upload('user-123/profile.jpg', file)

Under the hood, it checks your RLS (Row Level Security) policies—we’ll cover how to configure those in detail later.

Global CDN Included

Uploaded files are automatically distributed via Cloudflare CDN. You don’t need to configure CloudFront or Cloudflare Workers yourself.

Open your browser’s developer tools and check the response header for cf-cache-status:

cf-cache-status: HIT

HIT means cache hit, MISS means cache miss. Public buckets typically have much higher hit rates than Private buckets because Private buckets have stricter caching policies.

Smart CDN: The “Auto-Invalidate” Cache Mechanism

This is a highlight of Supabase. With traditional CDNs, you have to manually purge the cache after updating a file or wait for the TTL to expire. Supabase’s Smart CDN automatically syncs file metadata to edge nodes—when a file is updated, it propagates globally within 60 seconds.

But don’t get too excited—60 seconds is still quite long for real-time scenarios. If you need instant invalidation, you’ll need to use the cacheNonce parameter mentioned later.


2. Comparison of Three Access Control Modes

This is where many people get confused. Public bucket, Private bucket, Signed URL—three modes, three different use cases. Choose wrong and you’ll either get pathetic cache hit rates or security issues.

3
Access Control Modes

2.1 Public Bucket: Best for Public Resources

If your files are meant for everyone to see—website logos, blog images, public documents—use a Public bucket directly.

Advantages:

  • Cleanest URL: https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg
  • Highest cache hit rate, CDN responds directly without Auth validation
  • Simplest code, one line does it
// Get Public URL
const { data } = supabase.storage
  .from('public-images')
  .getPublicUrl('hero-banner.jpg')

console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/public-images/hero-banner.jpg

Use Cases:

  • User avatars (publicly displayed)
  • Blog images
  • Website static assets
  • Public documents

2.2 Private Bucket + Signed URL: Standard Solution for Private Files

Some files shouldn’t be public—contracts uploaded by users, member-exclusive content, sensitive documents. Use a Private bucket, then generate a time-limited Signed URL.

// Generate a 1-hour valid access link
const { data, error } = await supabase.storage
  .from('private-docs')
  .createSignedUrl('contracts/user-123.pdf', 3600) // 3600 seconds = 1 hour

console.log(data.signedUrl)
// https://xxx.supabase.co/storage/v1/object/sign/private-docs/contracts/user-123.pdf?token=xxx

Note: Signed URLs are different each time you generate them, which affects cache hit rate. If you frequently generate new Signed URLs, the CDN will keep missing.

Optimization Tip: For the same user accessing the same file multiple times within a short period, cache the Signed URL in the frontend or Redis instead of regenerating it every time.

2.3 RLS Policies: Fine-Grained Permission Control

This is the most powerful but easily overlooked feature. You can define RLS policies on the storage.objects table to precisely control who can operate which files.

Scenario 1: Users Can Only Upload to Their Own Folder

-- Create policy on storage.objects table
CREATE POLICY "Users can upload to own folder"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'avatars' 
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- Explanation: name is the full file path, e.g., 'user-123/avatar.jpg'
-- storage.foldername(name)[1] gets the first folder name, i.e., 'user-123'
-- auth.uid() is the current logged-in user's ID
-- Upload is allowed only when both match

Scenario 2: Admins Can Access All Files

CREATE POLICY "Admins can access all"
ON storage.objects FOR ALL
USING (
  auth.jwt() ->> 'role' = 'admin'
);

Scenario 3: Only Members Can Download Specific Content

CREATE POLICY "Members can download premium content"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'premium-content'
  AND EXISTS (
    SELECT 1 FROM user_subscriptions
    WHERE user_id = auth.uid()
    AND status = 'active'
  )
);

These three policies combined can cover most business scenarios.


3. File Upload in Practice

Finally, let’s get hands-on.

3.1 Standard Upload: Quick Solution for Small Files

For files under 5MB, just use the upload method directly.

// React upload component
import { useState } from 'react'
import { supabase } from './supabase-client'

export function AvatarUpload() {
  const [uploading, setUploading] = useState(false)
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null)

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    setUploading(true)
    
    const fileExt = file.name.split('.').pop()
    const fileName = `${Date.now()}.${fileExt}`
    const filePath = `avatars/${fileName}`

    const { error } = await supabase.storage
      .from('public-images')
      .upload(filePath, file, {
        cacheControl: '3600', // Browser cache for 1 hour
        upsert: false // Don't overwrite existing files
      })

    if (error) {
      alert('Upload failed: ' + error.message)
    } else {
      const { data } = supabase.storage
        .from('public-images')
        .getPublicUrl(filePath)
      setAvatarUrl(data.publicUrl)
    }

    setUploading(false)
  }

  return (
    <div>
      <input 
        type="file" 
        accept="image/*" 
        onChange={handleUpload}
        disabled={uploading}
      />
      {avatarUrl && <img src={avatarUrl} alt="avatar" />}
      {uploading && <p>Uploading...</p>}
    </div>
  )
}

A few details:

  • cacheControl sets browser cache duration, separate from CDN cache
  • upsert: false prevents accidental overwrites; change to true if you want to overwrite
  • Use timestamps in filenames to avoid conflicts, or UUIDs

3.2 TUS Chunked Upload: Stable Solution for Large Files

For files over 5MB or unstable network environments, use TUS chunked uploads.

Key Constraint: chunkSize must be 6MB, no exceptions. This is a hardcoded limit in Supabase.

Validity Period: Upload URLs are valid for 24 hours; you’ll need to regenerate if expired.

First, install dependencies:

npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus

Complete code:

import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus'
import { supabase } from './supabase-client'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'

export function LargeFileUploader() {
  const uppy = new Uppy({
    restrictions: {
      maxFileSize: 100 * 1024 * 1024, // 100MB
      allowedFileTypes: ['video/*', 'image/*']
    }
  })

  // Get Supabase session token
  const getSession = async () => {
    const { data: { session } } = await supabase.auth.getSession()
    return session?.access_token || ''
  }

  uppy.use(Tus, {
    endpoint: 'https://xxx.supabase.co/storage/v1/upload/resumable',
    chunkSize: 6 * 1024 * 1024, // Must be 6MB
    async onBeforeRequest(req) {
      const token = await getSession()
      req.setHeader('Authorization', `Bearer ${token}`)
    },
    onAfterResponse(req, res) {
      // Get file path after upload completes
      const location = res.getHeader('Location')
      console.log('File uploaded to:', location)
    }
  })

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      <Dashboard uppy={uppy} />
    </div>
  )
}

Troubleshooting: If your chunked upload stalls at 6MB, check:

  1. Is chunkSize exactly 6MB (must be precise)
  2. Has the token expired (24-hour validity)
  3. Does your RLS policy allow INSERT (refer to GitHub Issue #563)

3.3 Presigned Upload URL: Third-Party Upload Authorization

Sometimes you need to let users upload directly without exposing your service_role key. Use createSignedUploadUrl to generate a presigned upload URL.

// Server-side: generate upload URL
const { data, error } = await supabase.storage
  .from('user-uploads')
  .createSignedUploadUrl('documents/report.pdf')

// data.signedUrl can be passed to frontend for direct upload
// Frontend doesn't need to know the service_role key

4. CDN and Image Optimization

4.1 Smart CDN Mechanism Explained

As mentioned earlier, Smart CDN automatically invalidates cache after file updates. But the 60-second propagation delay can sometimes be frustrating.

Best Practices:

  1. For Frequently Updated Files, Upload to New Paths
// Don't do this: update the same file each time
await storage.from('images').upload('logo.png', file, { upsert: true })

// Do this: generate new filename each time
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
  1. Use cacheNonce to Force Bypass Cache
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('logo.png', {
    cacheNonce: Date.now().toString() // Different every request
  })
  1. Reuse Signed URLs

For the same user accessing the same file, store the Signed URL instead of regenerating it each time.

4.2 Image Transformation and Auto-Optimization

Supabase supports real-time image transformation—width, height, quality, and format can all be adjusted.

Limitations:

  • Width/Height: 1-2500px
  • File size: ≤25MB
  • Resolution: ≤50MP
// Get thumbnail
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('hero.jpg', {
    transform: {
      width: 300,
      height: 200,
      resize: 'cover', // or 'contain', 'fill'
      quality: 80,
      format: 'webp' // Auto-convert to WebP
    }
  })

Next.js Image Loader Integration:

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/supabase-image-loader.js'
  }
}
// lib/supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
  const url = new URL(src)
  url.searchParams.set('width', width.toString())
  url.searchParams.set('quality', (quality || 75).toString())
  url.searchParams.set('format', 'webp')
  return url.toString()
}

Pricing: Image transformation is $5/1000 origin images. If your images are heavily transformed (e.g., multiple sizes for responsive images), factor this into your cost calculations.


5. Cost Comparison and Selection Guide

Many people care about this. Let me compare the mainstream solutions.

5.1 Price Comparison Table

ServiceStorage FeeEgress FeeFree TierFeatures
Supabase StorageS3-based pricingCDN extraPro plan includedAuth integration, RLS
Cloudflare R2$0.015/GBZero10GB + 1M opsZero egress fee
AWS S3$0.023/GB$0.09/GB5GB/12 monthsStrongest ecosystem
DigitalOcean Spaces$5/250GBIncludedNoneFixed pricing
$0
Cloudflare R2 Egress Fee

5.2 Selection Decision

High Download Volume → R2

If your files will be downloaded frequently (e.g., image sharing sites, video hosting), R2’s zero egress fee can save you a lot. S3 charges nearly 10 cents per GB for egress—painful when traffic scales.

Need Auth Integration → Supabase Storage

If you’re already using Supabase’s Auth and database, Storage’s integration is seamless. User permission control and RLS policies can be reused directly.

Deep AWS Ecosystem User → S3

Lambda, CloudFront, S3 Select, S3 Glacier—if your architecture is already tied to AWS, the cost of switching might outweigh the savings.

Fixed Budget, Predictable Traffic → DigitalOcean Spaces

Fixed monthly fee, suitable for small projects that don’t want to worry about usage-based billing.

5.3 Cost Optimization Tips

  1. Lifecycle Policies: Auto-archive old files to Glacier
  2. Image Compression: Compress before upload, or use Supabase’s image transformation
  3. Public Buckets for Higher Cache Hit Rate: Make files public whenever possible
  4. Reuse Signed URLs: Reduce cache misses from repeated generation

6. Common Troubleshooting

6.1 File Still Shows Old Version After Update

Cause: Smart CDN’s 60-second propagation delay.

Solutions:

  • Wait 60 seconds
  • Upload to a new path
  • Use cacheNonce to bypass cache

6.2 Chunked Upload Stuck at 6MB

Cause: Incorrect chunkSize configuration.

Solution: Ensure chunkSize: 6 * 1024 * 1024, exact to the byte.

// Wrong: set to 5MB
chunkSize: 5 * 1024 * 1024 // Will stall

// Correct: must be 6MB
chunkSize: 6 * 1024 * 1024

6.3 Upload Returns 403 Forbidden

Cause: RLS policy not configured properly.

Troubleshooting Steps:

  1. Check if bucket is Public or Private
  2. Check RLS policies on the storage.objects table
  3. Ensure policy allows INSERT operation
-- View existing policies
SELECT * FROM pg_policies WHERE tablename = 'objects';

-- Add policy to allow upload
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');

6.4 Signed URL Cannot Be Accessed

Cause: URL expired or invalid token.

Solutions:

  • Check if expiration time is reasonable
  • Ensure token wasn’t truncated
  • For testing, generate a long-duration URL first (e.g., 24 hours)

Summary

Let’s recap the key points:

  • Public buckets are best for public resources with highest cache hit rates
  • Private bucket + Signed URL works well for private files; mind the caching strategy
  • RLS policies provide fine-grained permission control—don’t overlook this feature
  • TUS chunked uploads handle large files; chunkSize must be 6MB
  • Smart CDN auto-invalidates cache but has a 60-second delay
  • Selection: Choose R2 for high download volume, Supabase Storage for Auth integration, S3 for AWS ecosystem

If you’re already using Supabase’s database and authentication, Storage is a natural choice. But if you just need object storage with high traffic and budget sensitivity, R2’s zero egress fee is indeed tempting.

Any questions or pitfalls you’ve encountered with Supabase Storage? Feel free to discuss in the comments.

Complete Supabase Storage File Upload Workflow

From creating a bucket to configuring RLS policies for secure, controlled file uploads

⏱️ Estimated time: 30 min

  1. 1

    Step1: Create Storage Bucket

    Create a bucket in Supabase Dashboard:

    • Go to Storage page, click "Create a new bucket"
    • Enter bucket name (e.g., avatars, documents)
    • Choose Public or Private mode
    • Public bucket files are directly accessible, Private requires signed URLs
  2. 2

    Step2: Configure RLS Policies

    Define access control on storage.objects table:

    ```sql
    -- Users can only manage their own files
    CREATE POLICY "Users manage own files"
    ON storage.objects FOR ALL
    USING (auth.uid()::text = (storage.foldername(name))[1]);
    ```

    • bucket_id matches target bucket
    • auth.uid() gets current user ID
    • storage.foldername() parses file path
  3. 3

    Step3: Implement Standard Upload

    Use upload method for small files (<5MB):

    ```typescript
    const { error } = await supabase.storage
    .from('bucket-name')
    .upload('path/file.jpg', file, {
    cacheControl: '3600',
    upsert: false
    });
    ```

    • cacheControl sets browser cache duration
    • upsert: false prevents overwriting existing files
  4. 4

    Step4: Configure TUS Chunked Upload

    Handle large files (>5MB) with TUS protocol:

    • Install dependencies: npm install @uppy/tus tus-js-client
    • Set chunkSize: 6 * 1024 * 1024 (must be 6MB)
    • Configure Authorization header to pass JWT token
    • Upload URLs valid for 24 hours
  5. 5

    Step5: Optimize CDN Caching

    Key techniques to improve cache hit rate:

    • Public buckets have highest cache hit rate
    • For frequently updated files, use new paths instead of overwriting
    • Use cacheNonce to force cache refresh
    • Cache and reuse Signed URLs to avoid regeneration

FAQ

Why must Supabase Storage's chunkSize be 6MB?
This is a hardcoded server-side limit in Supabase. If set to other values (e.g., 5MB), uploads will stall. Make sure your code is configured exactly as: chunkSize: 6 * 1024 * 1024.
How to choose between Public bucket and Private bucket?
Choose based on file access permissions:

• Public bucket: Website static assets, public images, blog images—accessible to anyone
• Private bucket: User private files, member content, sensitive documents—require Signed URL or RLS policy for access control
Why does the file still show the old version after update?
Smart CDN's cache invalidation takes up to 60 seconds to propagate to global nodes. Solutions: wait 60 seconds, upload to a new path (recommended), or use the cacheNonce parameter to force bypass cache.
Which is cheaper: Supabase Storage or Cloudflare R2?
It depends on your use case:

• High download volume: R2's zero egress fee saves money (S3 charges $0.09/GB egress)
• Need Auth integration: Supabase Storage is more convenient (RLS policies can be reused)
• Already using Supabase: Storage is a seamless choice
• Pure object storage: R2 costs less
How do RLS policies restrict users to only access their own files?
Use storage.foldername() to parse file paths, combined with auth.uid() to match user IDs:

```sql
CREATE POLICY "Users own files"
ON storage.objects FOR ALL
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
```

This ensures users can only operate files where the first path segment is their own.
What to do about low Signed URL cache hit rate?
Signed URLs are different each time they're generated, causing CDN cache misses. Optimization: cache Signed URLs in frontend or Redis for reuse by the same user within a short period; or use RLS policies for access control and serve files via Public bucket.

References

9 min read · Published on: Apr 14, 2026 · Modified on: Apr 14, 2026

Comments

Sign in with GitHub to leave a comment