← Back to Tutorials Chapter 10

Storage: Delete and RLS

File Deletion

Removing files from Supabase Storage is straightforward with the remove method. Pass an array of file paths to delete multiple objects in a single call:

const { data, error } = await supabase
  .storage
  .from('avatars')
  .remove(['user-123/profile.png']);

if (error) {
  console.error('Delete failed:', error.message);
} else {
  console.log('Deleted files:', data);
}

The response contains the list of successfully deleted file objects. For bulk cleanup — like removing all images for a deleted user account — pass multiple paths:

const { data, error } = await supabase
  .storage
  .from('documents')
  .remove([
    'user-123/receipt-1.pdf',
    'user-123/receipt-2.pdf',
    'user-123/receipt-3.pdf'
  ]);

Folder Deletion

Supabase Storage does not have true folders — paths are prefixes on object keys. To "delete a folder," you must list all files under that prefix and remove them individually:

const { data: files, error: listError } = await supabase
  .storage
  .from('documents')
  .list('user-123');

if (files) {
  const paths = files.map(f => `user-123/${f.name}`);
  const { data, error } = await supabase
    .storage
    .from('documents')
    .remove(paths);
}

This two-step pattern — list then remove — is the standard approach for cleaning up entire user directories. Consider wrapping it in a server-side function or database trigger that fires when a user account is deleted, so orphaned files never accumulate.

Storage RLS Policies

Row Level Security applies to the storage.objects table just like any other table in your database. Each policy targets one of four operations: SELECT, INSERT, UPDATE, or DELETE. Enabling RLS on the storage schema is the first step — then you define policies per bucket using standard SQL.

A common setup is a public bucket for avatars with wide read access and restricted write access:

-- Allow anyone to read avatars
CREATE POLICY "Public read for avatars"
ON storage.objects FOR SELECT
TO public
USING (bucket_id = 'avatars');

-- Allow authenticated users to upload to avatars
CREATE POLICY "Auth users can upload avatars"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'avatars');

Policy Examples

The most powerful pattern uses auth.uid() to tie file ownership to the authenticated user. When you upload a file with a path that includes the user's ID, a policy can enforce that users only manage their own files:

-- Users can only delete their own files
CREATE POLICY "Users can delete their own files"
ON storage.objects FOR DELETE
TO authenticated
USING (
  bucket_id = 'documents'
  AND auth.uid() = owner
);

-- Users can only view their own documents
CREATE POLICY "Users can view their own documents"
ON storage.objects FOR SELECT
TO authenticated
USING (
  bucket_id = 'documents'
  AND auth.uid() = owner
);

The owner column on storage.objects is automatically set to the authenticated user's ID when they upload a file. This means ownership-based RLS requires no extra work on the upload side — just enforce it at the policy level. For admin bypass, add a role check:

CREATE POLICY "Admins can delete any file"
ON storage.objects FOR DELETE
TO authenticated
USING (
  bucket_id = 'documents'
  AND (auth.jwt() ->> 'role')::text = 'admin'
);

Bucket-Level vs Object-Level Security

Bucket visibility (public vs private) is a coarse access control — it determines whether the storage API allows anonymous reads. RLS policies on storage.objects give you fine-grained, row-level control. For maximum security, set all buckets to private and rely entirely on RLS and signed URLs. This ensures that even if someone guesses a file URL, the policy engine checks authorization before serving the object. Public buckets are convenient for truly public assets like avatars or blog images, but should never be used for sensitive data.

File Management Best Practices

storage.objects metadata. Every uploaded file is tracked in the storage.objects table with columns including name, bucket_id, owner (the user who uploaded it), metadata (a JSON object with size, mimetype, and cacheControl), created_at, and updated_at. You can query this table directly if you enable RLS appropriately, which is useful for building admin dashboards or usage reports.

Common Pitfalls

Checking Ownership Before Deletion

Even with RLS policies in place, you may want to verify ownership on the client side for immediate feedback:

async function deleteFile(filePath) {
  const { data: file, error: fetchError } = await supabase
    .storage
    .from('documents')
    .list('', { search: filePath });

  if (fetchError || !file.length) {
    console.error('File not found');
    return;
  }

  // RLS will reject if not owner, but we can check the owner column
  const { data, error } = await supabase
    .storage
    .from('documents')
    .remove([filePath]);

  if (error) {
    alert('You can only delete your own files.');
  } else {
    alert('File deleted successfully.');
  }
}

This pattern gives the user clear feedback while relying on RLS as the authoritative security layer. Never skip server-side enforcement just because you checked on the client — always use RLS as your safety net.

Exercise: Create an RLS policy so users can only delete their own uploaded files. Start by setting all storage buckets to private, enable RLS on storage.objects, and write a FOR DELETE policy using auth.uid() = owner. Test by uploading two files from different user accounts and verifying that each user can only delete their own uploads.