Cambiar tema

Supabase Storage en la práctica: subida de archivos, CDN y control de acceso

Easton editorial illustration: cost-quality-speed triangle

La semana pasada un lector preguntó: «¿S3 o Cloudflare R2 para avatares de usuario?»

Lo he vivido en dos proyectos. S3 con políticas IAM es un dolor; R2 es barato pero montas auth aparte. Migré a Supabase Storage —no es bala de plata— pero si ya usas Auth y base de datos, el combo funciona muy bien.

Aquí van mecanismos, tres modos de acceso, trampas de archivos grandes, CDN y costos frente a R2/S3. Código ejecutable.


1. Arquitectura central de Supabase Storage

Primero: Supabase Storage usa AWS S3 por debajo.

Una capa fina encima: operas con el SDK JavaScript sin IAM ni credenciales AWS complejas.

Vinculación automática con Auth

Lo que más me gusta: creas un bucket y el JWT de supabase.auth.getUser() controla quién sube y descarga. Sin otro sistema de permisos.

// La subida lleva identidad de usuario
const { data, error } = await supabase.storage
  .from('avatars')
  .upload('user-123/profile.jpg', file)

Debajo revisa RLS —más adelante cómo configurarlo.

CDN global automático

Los archivos se sirven por Cloudflare CDN sin configurar CloudFront ni Workers.

En DevTools, mira cf-cache-status:

cf-cache-status: HIT

HIT = caché; MISS = no. Public bucket suele tener mejor hit rate que Private.

Smart CDN: invalidación automática

Tradicionalmente purgas caché o esperas TTL. Smart CDN sincroniza metadata al edge; tras actualizar, efecto global en hasta 60 s.

Para tiempo real estricto, usa cacheNonce más adelante.


2. Tres modos de control de acceso

Public, Private y Signed URL —tres escenarios. Elegir mal baja caché o rompe seguridad.

3
Modos de acceso
Public, Private y Signed URL

2.1 Public Bucket: recursos abiertos

Logo, imágenes de blog, docs públicos → Public bucket.

Ventajas:

  • URL simple: https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg
  • Máximo hit rate; CDN responde sin Auth
  • Código mínimo
const { data } = supabase.storage
  .from('public-images')
  .getPublicUrl('hero-banner.jpg')

console.log(data.publicUrl)

Casos: avatares públicos, blog, estáticos, documentos abiertos.

2.2 Private Bucket + Signed URL

Contratos, contenido premium, docs sensibles → Private + URL firmada temporal.

const { data, error } = await supabase.storage
  .from('private-docs')
  .createSignedUrl('contracts/user-123.pdf', 3600)

console.log(data.signedUrl)

Cada Signed URL es distinta → peor caché. Reutiliza en frontend/Redis si el mismo usuario accede pronto.

2.3 Políticas RLS: permisos finos

En storage.objects defines quién hace qué.

Solo carpeta propia:

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]
);

Admin accede a todo:

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

Solo miembros descargan premium:

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'
  )
);

Con estas tres cubres la mayoría de casos.


3. Subida de archivos en la práctica

3.1 Subida estándar (<5 MB)

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',
        upsert: false
      })

    if (error) {
      alert('Error de subida: ' + 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>Subiendo...</p>}
    </div>
  )
}
  • cacheControl: caché del navegador (no CDN)
  • upsert: false evita sobrescritura accidental
  • Timestamp o UUID en el nombre

3.2 TUS: archivos grandes

>5 MB o red inestable → TUS.

chunkSize obligatorio: 6 MB. URL válida 24 h.

npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus
import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus'
import { supabase } from './supabase-client'

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

  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,
    async onBeforeRequest(req) {
      const token = await getSession()
      req.setHeader('Authorization', `Bearer ${token}`)
    }
  })

  return <Dashboard uppy={uppy} />
}

Si se queda en 6 MB: verifica chunkSize exacto, token no expirado, RLS permite INSERT.

3.3 Presigned Upload URL

Subida desde cliente sin exponer service_role:

const { data, error } = await supabase.storage
  .from('user-uploads')
  .createSignedUploadUrl('documents/report.pdf')

4. CDN e imágenes

4.1 Smart CDN

60 s de propagación a veces molesta.

  1. Nueva ruta en cada actualización (logo-${version}.png)
  2. cacheNonce para saltar caché
  3. Reutilizar Signed URL

4.2 Transformación de imágenes

Límites: 1-2500 px, ≤25 MB, ≤50 MP.

const { data } = supabase.storage
  .from('images')
  .getPublicUrl('hero.jpg', {
    transform: {
      width: 300,
      height: 200,
      resize: 'cover',
      quality: 80,
      format: 'webp'
    }
  })

Integración Next.js con loader personalizado. Facturación: $5/1000 imágenes origin.


5. Costos y elección

Muchos se fijan en el precio. Aquí va una comparativa directa.

5.1 Tabla de precios

ServicioAlmacenamientoEgressGratisNotas
Supabase StoragePrecio S3CDN aparteEn ProAuth, RLS
Cloudflare R2$0.015/GB$010 GBSin egress
AWS S3$0.023/GB$0.09/GB5 GB/12 mesesEcosistema
DigitalOcean Spaces$5/250GBIncluidoTarifa fija
$0
Egress en Cloudflare R2

5.2 Cómo elegir

Mucho volumen de descargas → R2

Si tus archivos se descargan a menudo (sitio de imágenes, vídeo), el egress cero de R2 ahorra mucho. S3 cobra casi 10 centavos por GB de salida; con tráfico alto duele.

Auth integrado → Supabase Storage

Si ya usas Auth y base de datos de Supabase, Storage encaja bien: permisos y RLS se reutilizan.

Ecosistema AWS → S3

Lambda, CloudFront, S3 Select, Glacier: si ya estás en AWS, cambiar puede costar más de lo que ahorras.

Presupuesto fijo y tráfico predecible → DigitalOcean Spaces

Tarifa mensual fija, ideal para proyectos pequeños sin sorpresas en la factura.

5.3 Optimización de costos

  1. Políticas de lifecycle: archivos viejos a Glacier u otro tier frío
  2. Compresión de imágenes: antes de subir o con transformación de Supabase
  3. Public bucket para subir el hit rate de caché
  4. Reutilizar Signed URL y reducir MISS por regeneración

6. Solución de problemas

6.1 Versión antigua tras actualizar

Causa: retraso de propagación de Smart CDN (~60 s).

Solución:

  • Espera 60 segundos
  • Sube a una ruta nueva
  • Usa cacheNonce para saltar caché

6.2 TUS bloqueado en 6 MB

Causa: chunkSize mal configurado.

Solución: chunkSize: 6 * 1024 * 1024, exacto en bytes.

// Incorrecto: 5 MB
chunkSize: 5 * 1024 * 1024 // se queda colgado

// Correcto: 6 MB
chunkSize: 6 * 1024 * 1024

6.3 403 Forbidden

Causa: políticas RLS mal definidas.

Pasos:

  1. Comprueba si el bucket es Public o Private
  2. Revisa RLS en storage.objects
  3. Asegura una política que permita INSERT
-- Ver políticas existentes
SELECT * FROM pg_policies WHERE tablename = 'objects';

-- Permitir subida
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');

6.4 Signed URL no funciona

Causa: URL caducada o token inválido.

Solución:

  • Revisa que el TTL sea razonable
  • Comprueba que el token no esté truncado
  • En pruebas, genera una URL larga (p. ej. 24 h)

Resumen

  • Public para abiertos, mejor caché
  • Private + Signed URL para privados
  • RLS para control fino
  • TUS con chunkSize 6 MB
  • Smart CDN hasta 60 s de retraso
  • R2 si hay mucho egress; Supabase si ya usas Auth

¿Preguntas o trampas que hayas visto? Compártelas en comentarios.


Referencias

Flujo completo de subida en Supabase Storage

Desde crear el bucket hasta configurar RLS para subidas seguras y controladas

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Crear Storage Bucket

    En Supabase Dashboard:

    • Storage → "Create a new bucket"
    • Nombre (avatars, documents, etc.)
    • Modo Public o Private
    • Public: acceso directo; Private: requiere Signed URL
  2. 2

    Step 2: Configurar políticas RLS

    En storage.objects:

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

    • bucket_id del bucket objetivo
    • auth.uid() = ID del usuario
    • storage.foldername() parsea la ruta
  3. 3

    Step 3: Subida estándar

    upload para archivos &lt;5 MB:

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

    • cacheControl: caché del navegador
    • upsert: false evita sobrescribir
  4. 4

    Step 4: Configurar subida TUS

    Archivos grandes (&gt;5 MB):

    • npm install @uppy/tus tus-js-client
    • chunkSize: 6 * 1024 * 1024 (obligatorio 6 MB)
    • Header Authorization con JWT
    • URL de subida válida 24 horas
  5. 5

    Step 5: Optimizar caché CDN

    • Public bucket = mayor hit rate
    • Archivos que cambian a menudo: nueva ruta, no sobrescribir
    • cacheNonce para forzar refresco
    • Reutilizar Signed URL

FAQ

¿Por qué chunkSize debe ser 6 MB en Supabase Storage?
Es un límite fijo del servidor. Otro valor (p. ej. 5 MB) hace que la subida se quede bloqueada. Usa chunkSize: 6 * 1024 * 1024.
¿Public o Private bucket?
• Public: estáticos, imágenes de blog, recursos abiertos
• Private: archivos de usuario, contenido de membresía, documentos sensibles —Signed URL o RLS
¿Por qué sigo viendo la versión antigua tras actualizar?
Smart CDN tarda hasta 60 s en propagarse. Espera, sube a nueva ruta (recomendado) o usa cacheNonce.
¿Supabase Storage o Cloudflare R2?
• Muchas descargas: R2 sin costo de egress ($0.09/GB en S3)
• Auth integrado: Supabase Storage con RLS
• Ya usas Supabase: Storage encaja natural
• Solo objeto puro: R2 más barato
¿RLS para que cada usuario solo acceda a sus archivos?
storage.foldername() + auth.uid():

```sql
CREATE POLICY "Users own files"
ON storage.objects FOR ALL
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
```
¿Baja tasa de acierto con Signed URL?
Cada URL firmada es distinta → MISS en CDN. Cachea la URL en frontend o Redis y reutilízala; o usa RLS + Public bucket cuando sea posible.

6 min de lectura · Publicado el: 14 abr 2026 · Actualizado el: 21 ago 2026

Comentarios

Inicia sesión con GitHub para dejar un comentario

Easton BlogEaston Blog