Storage + RLS: How to Secure User Uploads in Supabase

Programming· 5 min read

Storage + RLS: How to Secure User Uploads in Supabase

I've been building with Supabase for three years. And most times I see a project with data leaks, the database isn't the culprit.

Storage is.

Specifically: misconfigured permissions on buckets.

Let me be direct. If you're using Supabase Storage to store documents, invoices, or anything sensitive from your users, and you don't have Row Level Security (RLS) configured correctly, you're exposing data you shouldn't.

The Problem: Bucket vs Path-Level Permissions

Supabase Storage has two levels of control:

Bucket Level: You control whether someone can read, write, or delete files in the entire bucket. It's like saying "this bucket is public" or "this bucket is private".

Path Level: You control access to specific files within the bucket. This is where real security lives.

Most developers stop at the bucket level. They configure something like:

```sql -- ❌ This is insufficient CREATE POLICY "Users can read their own bucket" ON storage.objects FOR SELECT USING (bucket_id = 'user-uploads'); ```

This policy says: "If you're authenticated and the file is in the `user-uploads` bucket, you can read it". Period. Any authenticated user can download any file.

That's not security. That's the illusion of security.

The Solution: RLS at Path Level

What you need is access control based on the file path. Typically, users should only access their own files.

Here's the correct implementation:

```sql -- ✅ Path level: Only access to own files CREATE POLICY "Users can only read their own files" ON storage.objects FOR SELECT USING ( bucket_id = 'user-uploads' AND (storage.foldername(name))[1] = auth.uid()::text );

CREATE POLICY "Users can only upload to their folder" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'user-uploads' AND (storage.foldername(name))[1] = auth.uid()::text );

CREATE POLICY "Users can only delete their own files" ON storage.objects FOR DELETE USING ( bucket_id = 'user-uploads' AND (storage.foldername(name))[1] = auth.uid()::text ); ```

This assumes your folder structure looks like this:

``` user-uploads/ ├── 123e4567-e89b-12d3-a456-426614174000/ │ ├── document.pdf │ └── invoice.jpg └── 987f6543-e89b-12d3-a456-426614174111/ ├── contract.docx └── quote.xlsx ```

Each user has their own folder identified by their UUID. The `storage.foldername()` function extracts the first folder level and compares it with the authenticated user's UID.

Why It Matters (And A Lot)

Supabase supports files up to 500GB. That's a lot of space to store sensitive data.

If you don't have RLS configured correctly, anyone who knows another user's UUID can download all their files. They don't need a password. They don't need anything. Just knowing the URL.

In a financial or medical application, that's a compliance disaster. Data protection regulations are clear: you're responsible for your users' data security.

The Trap: Signed URLs

Here's where it gets interesting. Many developers think they can skip RLS using "signed URLs" (time-limited URLs).

It's a valid strategy. But incomplete.

A signed URL is like a temporary invitation: "This user can access this file for the next 60 minutes". It's useful for sharing files, but it doesn't replace RLS.

Why? Because if a malicious user gets the signed URL, they can share it. Or if they intercept the request, they can reuse the URL.

The correct combination is:

1. RLS in the database: Fundamental control of who can access what 2. Signed URLs in the application: Temporary and granular control for specific cases

It's not one or the other. It's both.

```typescript // Example: Generate a secure signed URL const { data, error } = await supabase .storage .from('user-uploads') .createSignedUrl(`${userId}/document.pdf`, 3600); // 1 hour

if (error) throw error;

// The user gets this URL, but it only works if: // 1. RLS allows this user to access this file // 2. The URL hasn't expired return data.signedUrl; ```

Testing: Verify It Works

Don't trust your intuition. Test.

```typescript // Test 1: User A cannot read User B's files const userA = await supabase.auth.signUp({ email: 'a@example.com' }); const userB = await supabase.auth.signUp({ email: 'b@example.com' });

await supabase.auth.setSession(userA.session); const { data: fileB } = await supabase .storage .from('user-uploads') .download(`${userB.user.id}/secret.pdf`);

// Should fail with permission error console.assert(!fileB, 'SECURITY FAILURE: User A can read User B files');

// Test 2: User A can read their own files await supabase.auth.setSession(userA.session); const { data: fileA } = await supabase .storage .from('user-uploads') .download(`${userA.user.id}/myfile.pdf`);

console.assert(fileA, 'User A cannot read their own files'); ```

The Pattern in Production

Here's how I do it in my projects:

1. Clear structure: Folders by user, files identified by unique names 2. RLS mandatory: Configured before going to production 3. Signed URLs for sharing: If a user wants to share a file, generate a temporary URL 4. Audit logs: Record who downloaded what and when (optional, but recommended for sensitive data)

```sql -- Bonus: Table to audit downloads CREATE TABLE storage_audit ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id), file_path TEXT NOT NULL, action TEXT NOT NULL, -- 'download', 'upload', 'delete' created_at TIMESTAMP DEFAULT NOW() );

-- Trigger to log downloads CREATE TRIGGER audit_storage_download AFTER SELECT ON storage.objects FOR EACH ROW EXECUTE FUNCTION audit_storage_action('download'); ```

The Takeaway

Supabase Storage is powerful. It supports large files, it's scalable, and it integrates perfectly with your application.

But power without security is irresponsibility.

Before uploading a user's file to production, make sure:

1. You have RLS configured at path level 2. You tested that one user cannot access another user's files 3. You understand the difference between bucket and path-level permissions

It's not complicated. But it's critical.

And it's the difference between an application that respects your users' data and one that doesn't.