Storage + RLS: Cómo Asegurar las Descargas de tus Usuarios en Supabase

Programación· 5 min de lectura

Storage + RLS: Cómo Asegurar las Descargas de tus Usuarios en Supabase

Llevo tres años construyendo con Supabase. Y la mayoría de veces que veo un proyecto con fugas de datos, la culpa no es de la base de datos.

Es del storage.

Específicamente: permisos mal configurados en los buckets.

Voy a ser directo. Si estás usando Supabase Storage para guardar documentos, facturas, o cualquier cosa sensible de tus usuarios, y no tienes Row Level Security (RLS) configurado correctamente, estás exponiendo datos que no deberías.

El Problema: Bucket vs Path-Level Permissions

Supabase Storage tiene dos niveles de control:

Nivel de Bucket: Controlas si alguien puede leer, escribir o eliminar archivos en todo el bucket. Es como decir "este bucket es público" o "este bucket es privado".

Nivel de Path: Controlas acceso a archivos específicos dentro del bucket. Es donde vive la verdadera seguridad.

La mayoría de desarrolladores se quedan en el nivel de bucket. Configuran algo como:

```sql -- ❌ Esto es insuficiente CREATE POLICY "Usuarios pueden leer su propio bucket" ON storage.objects FOR SELECT USING (bucket_id = 'user-uploads'); ```

Esta política dice: "Si estás autenticado y el archivo está en el bucket `user-uploads`, puedes leerlo". Punto. Cualquier usuario autenticado puede descargar cualquier archivo.

No es seguridad. Es la ilusión de seguridad.

La Solución: RLS a Nivel de Path

Lo que necesitas es controlar acceso basado en la ruta del archivo. Típicamente, los usuarios deberían acceder solo a sus propios archivos.

Aquí está la implementación correcta:

```sql -- ✅ Nivel de path: Solo acceso a archivos propios CREATE POLICY "Los usuarios solo leen sus propios archivos" ON storage.objects FOR SELECT USING ( bucket_id = 'user-uploads' AND (storage.foldername(name))[1] = auth.uid()::text );

CREATE POLICY "Los usuarios solo suben a su carpeta" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'user-uploads' AND (storage.foldername(name))[1] = auth.uid()::text );

CREATE POLICY "Los usuarios solo eliminan sus propios archivos" ON storage.objects FOR DELETE USING ( bucket_id = 'user-uploads' AND (storage.foldername(name))[1] = auth.uid()::text ); ```

Esto asume que tu estructura de carpetas es así:

``` user-uploads/ ├── 123e4567-e89b-12d3-a456-426614174000/ │ ├── documento.pdf │ └── factura.jpg └── 987f6543-e89b-12d3-a456-426614174111/ ├── contrato.docx └── presupuesto.xlsx ```

Cada usuario tiene su carpeta identificada por su UUID. La función `storage.foldername()` extrae el primer nivel de la carpeta y lo compara con el UID del usuario autenticado.

Por Qué Importa (Y Mucho)

Supabase soporta archivos hasta 500GB. Eso es mucho espacio para guardar datos sensibles.

Si no tienes RLS configurado correctamente, cualquier persona que sepa el UUID de otro usuario puede descargar todos sus archivos. No necesita contraseña. No necesita nada. Solo conocer la URL.

En una aplicación financiera o médica, eso es un desastre de cumplimiento normativo. En España, la LSSI-CE y la normativa de protección de datos son claras: tú eres responsable de la seguridad de los datos de tus usuarios.

La Trampa: Signed URLs

Aquí viene lo interesante. Muchos desarrolladores piensan que pueden saltarse RLS usando "signed URLs" (URLs firmadas que expiran).

Es una estrategia válida. Pero incompleta.

Una signed URL es como una invitación temporal: "Este usuario puede acceder a este archivo durante los próximos 60 minutos". Es útil para compartir archivos, pero no reemplaza RLS.

Por qué? Porque si un usuario malintencionado consigue la URL firmada, puede compartirla. O si intercepta la solicitud, puede reutilizar la URL.

La combinación correcta es:

1. RLS en la base de datos: Control fundamental de quién puede acceder a qué 2. Signed URLs en la aplicación: Control temporal y granular para casos específicos

No es uno u otro. Es ambos.

```typescript // Ejemplo: Generar una signed URL segura const { data, error } = await supabase .storage .from('user-uploads') .createSignedUrl(`${userId}/documento.pdf`, 3600); // 1 hora

if (error) throw error;

// El usuario obtiene esta URL, pero solo funciona si: // 1. RLS permite que este usuario acceda a este archivo // 2. La URL no ha expirado return data.signedUrl; ```

Testing: Verifica que Funciona

No confíes en tu intuición. Prueba.

```typescript // Test 1: Usuario A no puede leer archivos de Usuario B 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}/secreto.pdf`);

// Debería fallar con error de permisos console.assert(!fileB, 'FALLO DE SEGURIDAD: Usuario A puede leer archivos de Usuario B');

// Test 2: Usuario A puede leer sus propios archivos await supabase.auth.setSession(userA.session); const { data: fileA } = await supabase .storage .from('user-uploads') .download(`${userA.user.id}/miarchivo.pdf`);

console.assert(fileA, 'Usuario A no puede leer sus propios archivos'); ```

El Patrón en Producción

Aquí está cómo lo hago en mis proyectos:

1. Estructura clara: Carpetas por usuario, archivos identificados por nombre único 2. RLS obligatorio: Configurado antes de ir a producción 3. Signed URLs para compartir: Si un usuario quiere compartir un archivo, genera una URL temporal 4. Logs de auditoría: Registra quién descargó qué y cuándo (opcional, pero recomendado para datos sensibles)

```sql -- Bonus: Tabla para auditar descargas 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 para registrar descargas CREATE TRIGGER audit_storage_download AFTER SELECT ON storage.objects FOR EACH ROW EXECUTE FUNCTION audit_storage_action('download'); ```

El Takeaway

Supabase Storage es potente. Soporta archivos grandes, es escalable, y se integra perfectamente con tu aplicación.

Pero la potencia sin seguridad es irresponsabilidad.

Antes de subir un archivo de un usuario a producción, asegúrate de que:

1. Tienes RLS configurado a nivel de path 2. Probaste que un usuario no puede acceder a archivos de otro 3. Entiendes la diferencia entre permisos de bucket y path-level

No es complicado. Pero es crítico.

Y es la diferencia entre una aplicación que respeta los datos de tus usuarios y una que no.