# How to Secure Supabase Storage Files

- Tool: Supabase
- Difficulty: Intermediate
- Time required: 15-20 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## TL;DR

Secure Supabase Storage files by using private buckets, writing RLS policies on the storage.objects table, and generating signed URLs for temporary access. Private buckets deny all unauthenticated access by default. Add row-level security policies that scope file access to the owning user by matching the file path to auth.uid(), and use createSignedUrl() to share time-limited download links.

## Securing Files in Supabase Storage with Private Buckets, RLS, and Signed URLs

Supabase Storage uses the same Row Level Security system as your database tables, applied to the storage.objects table. This tutorial shows you how to create private buckets, write RLS policies that restrict file access to authenticated users or specific file owners, and generate signed URLs for time-limited sharing. You will build a secure file upload system where each user can only access their own files.

## Before you start

- A Supabase project with authentication configured
- Access to the SQL Editor in the Supabase Dashboard
- @supabase/supabase-js v2 installed in your project
- Basic understanding of Row Level Security concepts

## Step-by-step guide

### 1. Create a private storage bucket

In the Supabase Dashboard, go to Storage and click New Bucket. Name it documents and leave the Public bucket toggle OFF. A private bucket denies all unauthenticated access by default. Unlike public buckets where anyone with the URL can download files, private buckets require both authentication and an RLS policy to allow any operation. You can also create the bucket via SQL or the JS client.

```
-- Create a private bucket via SQL
insert into storage.buckets (id, name, public)
values ('documents', 'documents', false);

-- Or via the JS client (server-side only, requires service role key)
const { data, error } = await supabase.storage.createBucket('documents', {
  public: false,
  fileSizeLimit: 10485760 // 10MB
});
```

**Expected result:** A private bucket named 'documents' appears in the Storage section of the Dashboard with the public access indicator set to off.

### 2. Write RLS policies for user-scoped file access

Storage files are stored in the storage.objects table, and you write RLS policies on this table just like any other. The key pattern is to use a user-scoped folder structure where each user's files are stored under a path prefixed with their user ID. The storage.foldername() function extracts folder segments from the file path, and you compare the first segment to auth.uid() to ensure users can only access their own files.

```
-- Users can upload files to their own folder
create policy "Users upload own files"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'documents'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);

-- Users can read their own files
create policy "Users read own files"
on storage.objects for select
to authenticated
using (
  bucket_id = 'documents'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);

-- Users can delete their own files
create policy "Users delete own files"
on storage.objects for delete
to authenticated
using (
  bucket_id = 'documents'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);

-- Users can update (overwrite) their own files
create policy "Users update own files"
on storage.objects for update
to authenticated
using (
  bucket_id = 'documents'
  and (select auth.uid())::text = (storage.foldername(name))[1]
);
```

**Expected result:** RLS policies are active on storage.objects. Authenticated users can only upload, read, update, and delete files under their own user ID folder.

### 3. Upload files to the user-scoped folder from the frontend

When uploading from the client, construct the file path using the authenticated user's ID as the first folder segment. The Supabase JS client automatically includes the user's JWT in the request, which the RLS policy checks against the folder path. Use the upsert option if you want to allow overwriting existing files.

```
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

async function uploadFile(file: File) {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const filePath = `${user.id}/${file.name}`

  const { data, error } = await supabase.storage
    .from('documents')
    .upload(filePath, file, {
      cacheControl: '3600',
      upsert: false
    })

  if (error) throw error
  return data
}
```

**Expected result:** The file is uploaded to documents/{user_id}/filename.ext. Any attempt to upload to another user's folder is blocked by the RLS policy.

### 4. Generate signed URLs for temporary file access

For private buckets, you cannot use getPublicUrl() because the bucket is not publicly accessible. Instead, use createSignedUrl() to generate a time-limited URL that grants temporary access to a specific file. Signed URLs are ideal for sharing files with external users, rendering private images in the browser, or creating download links that expire. The expiry time is in seconds.

```
// Generate a signed URL valid for 1 hour (3600 seconds)
const { data, error } = await supabase.storage
  .from('documents')
  .createSignedUrl(`${user.id}/report.pdf`, 3600)

if (data) {
  console.log('Download link:', data.signedUrl)
}

// Generate signed URLs for multiple files at once
const { data: urls, error: urlError } = await supabase.storage
  .from('documents')
  .createSignedUrls([
    `${user.id}/report.pdf`,
    `${user.id}/invoice.pdf`
  ], 3600)
```

**Expected result:** A signed URL is returned that grants temporary access to the file. After the expiry time, the URL stops working and returns a 400 error.

### 5. List files in a user's folder with proper RLS

Use the list method to show users their uploaded files. Because RLS is active, the list operation only returns files the user's policy allows them to see. Pass the user's ID as the path prefix to scope the listing to their folder. You can add pagination with limit and offset options for large file collections.

```
async function listUserFiles() {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const { data: files, error } = await supabase.storage
    .from('documents')
    .list(user.id, {
      limit: 100,
      offset: 0,
      sortBy: { column: 'created_at', order: 'desc' }
    })

  if (error) throw error
  return files
}
```

**Expected result:** An array of file objects is returned, showing only the files in the authenticated user's folder. Other users' files are invisible.

## Complete code example

File: `secure-storage.ts`

```typescript
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

// Upload a file to the authenticated user's private folder
export async function uploadFile(file: File) {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const filePath = `${user.id}/${file.name}`
  const { data, error } = await supabase.storage
    .from('documents')
    .upload(filePath, file, { cacheControl: '3600', upsert: false })

  if (error) throw error
  return data
}

// List all files in the authenticated user's folder
export async function listUserFiles() {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const { data, error } = await supabase.storage
    .from('documents')
    .list(user.id, { limit: 100, sortBy: { column: 'created_at', order: 'desc' } })

  if (error) throw error
  return data
}

// Generate a signed URL for temporary file access
export async function getSignedUrl(fileName: string, expiresIn = 3600) {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const { data, error } = await supabase.storage
    .from('documents')
    .createSignedUrl(`${user.id}/${fileName}`, expiresIn)

  if (error) throw error
  return data.signedUrl
}

// Delete a file from the authenticated user's folder
export async function deleteFile(fileName: string) {
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('Not authenticated')

  const { data, error } = await supabase.storage
    .from('documents')
    .remove([`${user.id}/${fileName}`])

  if (error) throw error
  return data
}
```

## Common mistakes

- **Using a public bucket when files should be restricted to authenticated users** — undefined Fix: Create the bucket with public: false. Public buckets allow anyone with the URL to download files, bypassing all access controls.
- **Uploading files without the user ID as the first folder segment, causing the RLS policy to block the operation** — undefined Fix: Always construct the file path as {user.id}/{filename}. The RLS policy checks storage.foldername(name)[1] against auth.uid().
- **Using getPublicUrl() on a private bucket and getting 400 errors** — undefined Fix: Private buckets do not support public URLs. Use createSignedUrl() with an expiry time instead.
- **Forgetting to add a SELECT RLS policy on storage.objects, causing list and download operations to return empty results** — undefined Fix: Add a SELECT policy alongside your INSERT policy. Without it, users can upload but cannot see or download their own files.

## Best practices

- Always use private buckets for user-uploaded content and sensitive documents
- Scope file paths with the user's ID as the first folder segment for easy RLS policy enforcement
- Add RLS policies for all four operations: SELECT, INSERT, UPDATE, and DELETE on storage.objects
- Use createSignedUrl() with the shortest practical expiry time for sharing private files
- Set fileSizeLimit on the bucket to prevent excessively large uploads at the storage level
- Verify the user with getUser() before any storage operation instead of relying on getSession()
- Add cacheControl headers when uploading to improve CDN performance for frequently accessed files
- Never expose the SUPABASE_SERVICE_ROLE_KEY to the client — it bypasses all storage RLS policies

## Frequently asked questions

### What is the difference between a public and private bucket in Supabase?

A public bucket allows anyone with the file URL to download it without authentication. A private bucket requires both authentication and a passing RLS policy on storage.objects before any operation is allowed.

### Can I make some files in a private bucket publicly accessible?

Not directly. A bucket is either public or private. To share specific files from a private bucket, generate signed URLs with createSignedUrl(). These URLs work for anyone but expire after the specified time.

### How long can a signed URL last?

Signed URLs can last up to 7 days (604800 seconds). Set the shortest expiry that meets your needs for security. Common values are 300 seconds for image display and 86400 seconds for email download links.

### Why do my storage uploads return a 403 error?

A 403 error means the RLS policy on storage.objects is blocking the operation. Check that you have an INSERT policy, the bucket_id matches, and the file path starts with the user's ID. Also verify the user is authenticated.

### Does the service role key bypass storage RLS policies?

Yes. The SUPABASE_SERVICE_ROLE_KEY bypasses all RLS policies including those on storage.objects. Never use it in client-side code. It is intended for server-side admin operations only.

### Can I restrict upload file types in Supabase Storage?

Supabase does not enforce file type restrictions at the bucket level. Validate file types in your client code before uploading, or write an Edge Function that checks the content-type header before storing the file.

### Can RapidDev help configure secure file storage for my Supabase project?

Yes. RapidDev can design your storage architecture, write RLS policies for complex access patterns, and implement secure upload and download flows tailored to your application.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-secure-supabase-storage-files
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-secure-supabase-storage-files
