Alternar tema

Supabase Storage na prática: upload de arquivos, CDN e controle de acesso

Easton editorial illustration: cost-quality-speed triangle

Na semana passada, um leitor me perguntou: “Para permitir que usuários enviem avatares, é melhor usar S3 ou Cloudflare R2?”

Parei para pensar. Já tive problemas com isso em dois projetos diferentes. Configurar as políticas de permissão do S3 quase me fez perder a cabeça; o R2 é barato, mas exige montar um sistema de autenticação por conta própria. Mais tarde, migrei os projetos para o Supabase Storage — não porque ele seja uma “bala de prata”, mas porque, se você já usa o Auth e o banco de dados do Supabase, o conjunto funciona muito bem.

Neste artigo, vou destrinchar os mecanismos centrais do Supabase Storage, três modelos de controle de acesso, as armadilhas do upload de arquivos grandes, técnicas de otimização da CDN e uma comparação de custos com R2 e S3. Todo o código pode ser executado diretamente.


1. Arquitetura central do Supabase Storage

Primeiro, é importante deixar uma coisa clara: por baixo dos panos, o Supabase Storage usa o AWS S3.

Porém, ele adiciona uma camada de abstração muito fina — fina o bastante para você operar tudo diretamente pelo SDK JavaScript, sem precisar lidar com o complexo sistema de credenciais e políticas IAM da AWS.

Integração automática com o Auth

Esse é o ponto de que mais gosto. Você cria um bucket no Supabase e pode usar diretamente o token JWT obtido por supabase.auth.getUser() para controlar quem pode fazer upload ou download. Não é preciso construir outro sistema de permissões.

// A identidade do usuário é enviada automaticamente no upload
const { data, error } = await supabase.storage
  .from('avatars')
  .upload('user-123/profile.jpg', file)

Por baixo dos panos, o serviço verifica suas políticas RLS (Row Level Security). Mais adiante, veremos em detalhes como configurá-las.

Distribuição automática por uma CDN global

Os arquivos enviados são distribuídos automaticamente pela CDN da Cloudflare. Você não precisa configurar CloudFront nem Cloudflare Workers por conta própria.

Abra as ferramentas de desenvolvimento do navegador e procure o header cf-cache-status na resposta:

cf-cache-status: HIT

HIT indica que o cache foi aproveitado; MISS, que não foi. Em geral, a taxa de acerto de um bucket público é bem maior que a de um bucket privado, porque este adota uma política de cache mais restrita.

Smart CDN: invalidação “automática” do cache

Esse é um dos destaques do Supabase. Em uma CDN tradicional, depois de atualizar um arquivo você precisa limpar o cache manualmente ou aguardar o TTL expirar. O Smart CDN do Supabase sincroniza automaticamente os metadados do arquivo com os pontos de presença. Quando o arquivo muda, a nova versão chega ao mundo todo em até 60 segundos.

Mas não comemore cedo demais: 60 segundos ainda é muito em cenários que exigem atualização em tempo real. Se você precisa que a mudança apareça imediatamente, terá de usar o parâmetro cacheNonce, apresentado mais adiante.


2. Comparação entre três modelos de controle de acesso

Muita gente se confunde aqui. Bucket público, bucket privado e Signed URL são três modelos diferentes, cada um indicado para determinados cenários. Uma escolha errada pode derrubar a taxa de acerto do cache ou até criar um problema de segurança.

3
modelos de controle de acesso
Public, Private e Signed URL atendem a cenários diferentes

2.1 Bucket público: a primeira opção para recursos abertos

Se os arquivos foram feitos para qualquer pessoa acessar — logo do site, imagens de artigos ou documentos públicos — use diretamente um bucket público.

Vantagens:

  • URL mais simples: https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg
  • Maior taxa de acerto do cache, pois a CDN responde diretamente, sem passar por uma validação do Auth
  • Código simples, resolvido em uma única chamada
// Obtém a URL pública
const { data } = supabase.storage
  .from('public-images')
  .getPublicUrl('hero-banner.jpg')

console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/public-images/hero-banner.jpg

Cenários indicados:

  • Avatares de usuários exibidos publicamente
  • Imagens de artigos
  • Recursos estáticos do site
  • Documentos públicos

2.2 Bucket privado + Signed URL: solução padrão para arquivos particulares

Alguns arquivos não podem ser públicos, como contratos enviados por usuários, conteúdo exclusivo para assinantes e documentos confidenciais. Nesse caso, use um bucket privado e gere uma Signed URL com prazo de validade.

// Gera um link de acesso válido por uma hora
const { data, error } = await supabase.storage
  .from('private-docs')
  .createSignedUrl('contracts/user-123.pdf', 3600) // 3600 segundos = 1 hora

console.log(data.signedUrl)
// https://xxx.supabase.co/storage/v1/object/sign/private-docs/contracts/user-123.pdf?token=xxx

Atenção: cada Signed URL gerada é diferente, o que afeta a taxa de acerto do cache. Se você gerar uma nova Signed URL a cada acesso, a CDN continuará respondendo com MISS.

Dica de otimização: quando o mesmo usuário acessa o mesmo arquivo várias vezes em um curto período, armazene a Signed URL no frontend ou no Redis, em vez de gerá-la novamente a cada solicitação.

2.3 Políticas RLS: controle de permissões granular

Esta é a parte mais poderosa e, ao mesmo tempo, mais ignorada. Você pode definir políticas RLS na tabela storage.objects para controlar com precisão quem pode operar cada arquivo.

Cenário 1: o usuário só pode enviar arquivos para a própria pasta

-- Cria uma política na tabela storage.objects
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]
);

-- Explicação: name é o caminho completo do arquivo, como 'user-123/avatar.jpg'
-- storage.foldername(name)[1] obtém o nome da primeira pasta, ou seja, 'user-123'
-- auth.uid() é o ID do usuário conectado
-- O upload só é permitido quando os dois valores correspondem

Cenário 2: administradores podem acessar todos os arquivos

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

Cenário 3: somente assinantes podem baixar determinado conteúdo

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

Combinadas, essas três políticas atendem à maioria dos cenários de negócio.


3. Upload de arquivos na prática

Finalmente, vamos colocar a mão na massa.

3.1 Upload padrão: envie arquivos pequenos rapidamente

Para arquivos menores que 5 MB, basta usar o método upload.

// Componente de upload em React
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', // Cache do navegador por uma hora
        upsert: false // Não sobrescreve um arquivo existente
      })

    if (error) {
      alert('Falha no upload: ' + 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>Enviando...</p>}
    </div>
  )
}

Alguns detalhes:

  • cacheControl define o tempo de cache do navegador, que não é a mesma coisa que o cache da CDN
  • upsert: false impede uma substituição acidental; para sobrescrever, mude o valor para true
  • O timestamp no nome evita duplicidades; você também pode usar um UUID

3.2 Upload em partes com TUS: uma solução estável para arquivos grandes

Para enviar arquivos com mais de 5 MB ou trabalhar em uma rede instável, use o upload em partes com TUS.

Limitação essencial: o chunkSize precisa ser de 6 MB e não pode ser alterado. Essa é uma limitação fixa do Supabase.

Validade: a URL de upload é válida por 24 horas. Depois disso, será preciso gerar outra.

Primeiro, instale as dependências:

npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus

Código completo:

import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus'
import { supabase } from './supabase-client'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'

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

  // Obtém o token da sessão do Supabase
  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, // Deve ser 6 MB
    async onBeforeRequest(req) {
      const token = await getSession()
      req.setHeader('Authorization', `Bearer ${token}`)
    },
    onAfterResponse(req, res) {
      // Obtém o caminho do arquivo após a conclusão do upload
      const location = res.getHeader('Location')
      console.log('File uploaded to:', location)
    }
  })

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      <Dashboard uppy={uppy} />
    </div>
  )
}

Solução de problemas: se o upload em partes travar aos 6 MB, confira:

  1. Se o chunkSize é de 6 MB, exatamente
  2. Se o token expirou; a validade é de 24 horas
  3. Se a política RLS permite INSERT; consulte a GitHub Issue #563

3.3 Presigned Upload URL: autorização para uploads de terceiros

Às vezes, você precisa permitir o upload direto pelo usuário sem expor a chave service_role. Use createSignedUploadUrl para gerar uma URL de upload pré-assinada.

// O servidor gera a URL de upload
const { data, error } = await supabase.storage
  .from('user-uploads')
  .createSignedUploadUrl('documents/report.pdf')

// data.signedUrl pode ser enviada ao frontend para fazer o upload diretamente
// O frontend não precisa conhecer a chave service_role

4. CDN e otimização de imagens

4.1 Como o Smart CDN funciona

Como vimos, o Smart CDN invalida o cache automaticamente depois que um arquivo é atualizado. Ainda assim, o atraso de até 60 segundos na propagação pode incomodar.

Boas práticas:

  1. Envie arquivos atualizados com frequência para um novo caminho
// Evite: atualizar sempre o mesmo arquivo
await storage.from('images').upload('logo.png', file, { upsert: true })

// Prefira: gerar um novo nome a cada atualização
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
  1. Use cacheNonce para ignorar o cache
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('logo.png', {
    cacheNonce: Date.now().toString() // Cada solicitação usa um valor diferente
  })
  1. Reutilize a Signed URL

Quando o mesmo usuário acessa o mesmo arquivo, armazene a Signed URL e evite gerar outra a cada vez.

4.2 Transformação e otimização automática de imagens

O Supabase permite transformar imagens em tempo real: você pode ajustar largura, altura, qualidade e formato.

Limites:

  • Largura e altura: 1–2500 px
  • Tamanho do arquivo: ≤25 MB
  • Resolução: ≤50 MP
// Obtém uma miniatura
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('hero.jpg', {
    transform: {
      width: 300,
      height: 200,
      resize: 'cover', // Ou 'contain' ou 'fill'
      quality: 80,
      format: 'webp' // Converte automaticamente para WebP
    }
  })

Integração com o Image Loader do Next.js:

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/supabase-image-loader.js'
  }
}
// lib/supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
  const url = new URL(src)
  url.searchParams.set('width', width.toString())
  url.searchParams.set('quality', (quality || 75).toString())
  url.searchParams.set('format', 'webp')
  return url.toString()
}

Preço: as transformações de imagem custam US$ 5 por 1.000 imagens de origem. Se suas imagens forem transformadas em muitas versões, como em vários tamanhos responsivos, inclua esse valor na conta.


5. Comparação de custos e recomendações de escolha

Muita gente se preocupa com esta parte. Vamos comparar as principais opções.

5.1 Tabela de preços

ServiçoArmazenamentoTaxa de saídaFranquia gratuitaDestaques
Supabase StorageBaseado nos preços do S3CDN cobrada à parteIncluída no plano ProIntegração com Auth e RLS
Cloudflare R2US$ 0,015/GBZero10 GB + 1 milhão de operaçõesSem taxa de saída
AWS S3US$ 0,023/GBUS$ 0,09/GB5 GB por 12 mesesEcossistema mais completo
DigitalOcean SpacesUS$ 5/250 GBIncluídaNenhumaPreço fixo
US$ 0
taxa de saída do Cloudflare R2

5.2 Como escolher

Muitos downloads → R2

Se seus arquivos serão baixados com frequência, como em um site de compartilhamento de imagens ou hospedagem de vídeos, a taxa de saída zero do R2 pode gerar uma grande economia. No S3, a saída custa quase dez centavos de dólar por GB, o que pesa bastante quando o tráfego aumenta.

Integração com Auth → Supabase Storage

Se você já usa o Auth e o banco de dados do Supabase, a integração com o Storage é muito simples. O controle de permissões dos usuários e as políticas RLS podem ser reutilizados diretamente.

Uso intenso do ecossistema AWS → S3

Lambda, CloudFront, S3 Select, S3 Glacier: se sua arquitetura já está vinculada à AWS, o custo da migração pode superar a economia obtida com outra solução.

Orçamento fixo e tráfego previsível → DigitalOcean Spaces

O preço mensal fixo é adequado para projetos pequenos que não querem se preocupar com cobrança por uso.

5.3 Recomendações para reduzir custos

  1. Políticas de ciclo de vida: arquive arquivos antigos automaticamente no Glacier
  2. Compressão de imagens: comprima antes do upload ou use as transformações de imagem do Supabase
  3. Buckets públicos para melhorar a taxa de acerto do cache: mantenha público tudo o que puder ser aberto
  4. Reutilização de Signed URLs: reduza a quantidade de URLs geradas e evite MISS recorrentes no cache

6. Solução de problemas comuns

6.1 A versão antiga continua aparecendo depois da atualização

Causa: atraso de até 60 segundos na propagação do Smart CDN.

Soluções:

  • Aguarde 60 segundos
  • Envie o arquivo para um novo caminho
  • Use cacheNonce para ignorar o cache

6.2 O upload em partes trava aos 6 MB

Causa: configuração incorreta do chunkSize.

Solução: defina exatamente chunkSize: 6 * 1024 * 1024, até o último byte.

// Errado: definido como 5 MB
chunkSize: 5 * 1024 * 1024 // O upload ficará travado

// Correto: deve ser 6 MB
chunkSize: 6 * 1024 * 1024

6.3 O upload retorna 403 Forbidden

Causa: política RLS configurada incorretamente.

Etapas de verificação:

  1. Confira se o bucket é Public ou Private
  2. Confira as políticas RLS da tabela storage.objects
  3. Verifique se a política permite a operação INSERT
-- Lista as políticas existentes
SELECT * FROM pg_policies WHERE tablename = 'objects';

-- Adiciona uma política que permite uploads
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');

6.4 A Signed URL não pode ser acessada

Causa: a URL expirou ou o token é inválido.

Soluções:

  • Confira se o prazo de validade é adequado
  • Verifique se o token não foi cortado
  • Durante os testes, gere uma URL com validade longa, como 24 horas

Resumo

Recapitulando os principais pontos:

  • Buckets públicos são ideais para recursos abertos e oferecem a melhor taxa de acerto do cache
  • Buckets privados + Signed URL são indicados para arquivos particulares; preste atenção à estratégia de cache
  • Políticas RLS oferecem controle de permissões granular e não devem ser ignoradas
  • Uploads em partes com TUS são apropriados para arquivos grandes, e o chunkSize precisa ser de 6 MB
  • Smart CDN invalida o cache automaticamente, mas há um atraso de até 60 segundos
  • Escolha: R2 para muitos downloads, Supabase Storage para integração com Auth e S3 para quem já usa o ecossistema AWS

Se você já usa o banco de dados e a autenticação do Supabase, adotar o Storage é um caminho natural. Mas, se precisa apenas de armazenamento de objetos e tem muito tráfego com orçamento apertado, a taxa de saída zero do R2 é realmente atraente.

Você ainda tem alguma dúvida sobre o Supabase Storage ou já enfrentou algum problema com ele? Compartilhe sua experiência nos comentários.


Referências

Fluxo completo de upload de arquivos no Supabase Storage

Da criação do bucket à configuração de políticas RLS, implemente uploads seguros e controlados

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Criar um bucket no Storage

    Crie um bucket no Supabase Dashboard:

    • Acesse a página Storage e clique em 'Create a new bucket'
    • Informe o nome do bucket, como avatars ou documents
    • Escolha o modo Public ou Private
    • Arquivos de buckets públicos podem ser acessados diretamente; buckets privados exigem uma URL assinada
  2. 2

    Step 2: Configurar políticas RLS

    Defina o controle de acesso na tabela storage.objects:

    ```sql
    -- Cada usuário só pode operar seus próprios arquivos
    CREATE POLICY "Users manage own files"
    ON storage.objects FOR ALL
    USING (auth.uid()::text = (storage.foldername(name))[1]);
    ```

    • bucket_id deve corresponder ao bucket de destino
    • auth.uid() obtém o ID do usuário atual
    • storage.foldername() interpreta o caminho do arquivo
  3. 3

    Step 3: Implementar o upload padrão

    Use o método upload para enviar arquivos pequenos (<5 MB):

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

    • cacheControl define a duração do cache do navegador
    • upsert: false impede que arquivos existentes sejam sobrescritos
  4. 4

    Step 4: Configurar upload em partes com TUS

    Para arquivos grandes (>5 MB), use o protocolo TUS:

    • Instale as dependências: npm install @uppy/tus tus-js-client
    • Defina chunkSize: 6 * 1024 * 1024 (deve ser 6 MB)
    • Configure o header Authorization para enviar o token JWT
    • A URL de upload é válida por 24 horas
  5. 5

    Step 5: Otimizar o cache da CDN

    Técnicas essenciais para melhorar a taxa de acerto do cache:

    • Buckets públicos oferecem a melhor taxa de acerto
    • Para arquivos atualizados com frequência, use um novo caminho em vez de sobrescrevê-los
    • Use cacheNonce para forçar a atualização do cache
    • Reutilize Signed URLs em cache, evitando gerá-las repetidamente

FAQ

Por que o chunkSize do Supabase Storage precisa ser de 6 MB?
Essa é uma limitação fixa no servidor do Supabase. Com outro valor, como 5 MB, o upload fica travado. Configure o código exatamente assim: chunkSize: 6 * 1024 * 1024.
Como escolher entre um bucket público e um bucket privado?
Escolha de acordo com a permissão de acesso necessária:

• Bucket público: recursos estáticos do site, imagens públicas e ilustrações de artigos — qualquer pessoa pode acessar
• Bucket privado: arquivos pessoais de usuários, conteúdo para assinantes e documentos confidenciais — o acesso exige uma Signed URL ou uma política RLS
Por que continuo vendo a versão antiga depois de atualizar um arquivo?
A invalidação de cache do Smart CDN pode levar até 60 segundos para chegar aos pontos de presença no mundo todo. Há três soluções: aguardar 60 segundos, enviar o arquivo para um novo caminho, que é a opção recomendada, ou usar o parâmetro cacheNonce para ignorar o cache.
Qual é mais barato: Supabase Storage ou Cloudflare R2?
Depende do seu cenário:

• Muitos downloads: o R2 economiza por não cobrar taxa de saída; no S3, ela custa US$ 0,09/GB
• Integração com Auth: o Supabase Storage é mais conveniente, pois reutiliza diretamente as políticas RLS
• Projeto que já usa Supabase: o Storage é a opção mais simples
• Armazenamento de objetos puro: o R2 tem custo menor
Como limitar uma política RLS para que cada usuário acesse somente os próprios arquivos?
Use storage.foldername() para interpretar o caminho do arquivo e combine-o com auth.uid() para comparar o ID do usuário:

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

Assim, o usuário só pode operar arquivos cujo primeiro segmento do caminho corresponda ao próprio ID.
O que fazer quando a taxa de acerto do cache de uma Signed URL é baixa?
Cada Signed URL gerada é diferente, o que causa um MISS na CDN. Para otimizar, armazene a Signed URL no frontend ou no Redis e reutilize-a por um curto período para o mesmo usuário. Outra opção é controlar o acesso com políticas RLS e servir o arquivo por um bucket público.

12 min de leitura · Publicado em: 14 abr 2026 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog