Alternar tema

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

Easton editorial illustration: orchestration hub with branches

Eu estava olhando a mensagem de erro no painel. Meia hora depois de colocar o upload de avatares em produção, chegou o relato de um usuário: o avatar de todo mundo havia virado o da mesma pessoa.

Ao investigar, encontrei o problema na configuração da política RLS do Storage — ela simplesmente não existia. O bucket era público, o caminho de upload não isolava os usuários e qualquer upload podia sobrescrever o arquivo de outra pessoa. Um descuido nas permissões quase se transformou em um incidente de produção.

O Supabase Storage é fácil de começar a usar, mas exige alguns cuidados para funcionar bem de verdade — controle de acesso, aceleração por CDN e transformação de imagens têm suas próprias armadilhas. Aqui reuni os problemas que enfrentei e o que aprendi ao resolvê-los.

1. Primeiros passos: upload padrão de arquivos

Vamos começar pelo básico: enviar um arquivo.

Criar um bucket

Abra o painel do Supabase, encontre Storage no menu lateral e clique em “New bucket”. Dê um nome ao bucket, como avatars para avatares ou posts para imagens dos artigos. Uma opção perguntará “Make this bucket public?” — não marque ainda; a seção sobre permissões explica essa escolha em detalhes.

Minha prática é guardar arquivos sensíveis em buckets privados e recursos estáticos em buckets públicos. Por padrão, começo com um bucket privado e ajusto depois, quando necessário.

Código de upload com o SDK

Supondo que @supabase/supabase-js já esteja instalado, o código é simples:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://your-project.supabase.co',
  'your-anon-key'
)

// Fazer upload do arquivo
async function uploadFile(file: File) {
  const filePath = `uploads/${Date.now()}-${file.name}`

  const { data, error } = await supabase.storage
    .from('avatars')  // Nome do bucket
    .upload(filePath, file, {
      cacheControl: '3600',  // Cache por 1 hora
      upsert: false  // Retorna erro se o arquivo já existir; não sobrescreve
    })

  if (error) {
    console.error('Falha no upload:', error.message)
    return null
  }

  return data.path  // Retorna o caminho do arquivo
}

Para ser sincero, já escrevi esse trecho de código mais de dez vezes. O ponto essencial é o desenho de filePath: adiante explico por que usar um prefixo com timestamp e como isolar os usuários.

Limites de tamanho dos arquivos

Segundo a documentação oficial, o upload padrão aceita arquivos de até 5 GB. Na prática, ele oferece a melhor experiência para arquivos menores que 6 MB. Acima de 6 MB, a recomendação é usar o protocolo TUS para permitir a retomada do upload.

O que é TUS? Em termos simples, ele permite retomar o envio de arquivos grandes. Se a conexão cair, o upload continua do ponto em que parou quando a rede voltar, em vez de recomeçar. Para vídeos e imagens grandes, a diferença é enorme: imagine a conexão cair quando o usuário já enviou 90% do arquivo. Sem TUS, seria preciso começar novamente.

O upload com TUS exige configuração adicional. Se você ainda não precisa dele, o upload padrão atende à maioria dos cenários.

// Exemplo de upload com TUS (recomendado para arquivos grandes)
const { data, error } = await supabase.storage
  .from('videos')
  .upload('large-video.mp4', file, {
    duplex: 'half',  // Ativa o upload por streaming
    // O TUS cuida automaticamente da retomada do upload
  })

2. Configuração de segurança: políticas RLS em detalhes

Agora voltamos ao problema que enfrentei às três da manhã: as permissões não estavam configuradas e os arquivos podiam ser sobrescritos livremente.

O Storage do Supabase, assim como o banco de dados, usa PostgreSQL por baixo. Por isso, o controle de acesso também segue o modelo RLS (Row Level Security). O bucket funciona como uma tabela, e cada arquivo corresponde a um registro.

Buckets públicos e privados

Ao criar um bucket, você escolhe entre “Public bucket” e “Private bucket”.

Public bucket: qualquer pessoa pode ler os arquivos, sem autenticação. É adequado para recursos estáticos públicos, como avatares e logotipos do site.

Private bucket: exige autenticação para acesso. Mas existe uma armadilha: a autenticação é apenas a porta de entrada. Quem pode ler ou gravar depende das políticas RLS.

Minha recomendação é criar um bucket privado por padrão, a menos que os arquivos sejam realmente públicos. É muito mais seguro configurar as permissões primeiro e abrir o acesso depois do que tornar tudo público e tentar corrigir em seguida.

Tipos de política RLS

Na página Policies do Storage, você verá quatro operações:

  • SELECT: ler arquivos (download ou obtenção da URL)
  • INSERT: enviar novos arquivos
  • UPDATE: atualizar ou sobrescrever arquivos existentes
  • DELETE: excluir arquivos

Cada operação pode ter sua própria política. A configuração mais comum é esta:

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

Esse SQL pode parecer um pouco complexo, então vamos dividi-lo:

  • auth.uid() obtém o ID do usuário autenticado
  • storage.foldername(name) extrai o nome do primeiro diretório do caminho do arquivo
  • Se o caminho for user123/avatar.jpg, por exemplo, o primeiro diretório será user123

Portanto, a lógica completa da política é esta: o usuário só pode operar em um arquivo quando o primeiro diretório do caminho corresponde ao seu ID. Essa é a ideia central do isolamento por usuário.

Como implementar o isolamento por usuário

Como fazer isso na prática? Coloque o ID do usuário no primeiro nível do caminho durante o upload:

async function uploadAvatar(userId: string, file: File) {
  // Estrutura do caminho: ID do usuário/nome do arquivo
  const filePath = `${userId}/avatar-${Date.now()}.jpg`

  const { data, error } = await supabase.storage
    .from('avatars')
    .upload(filePath, file)

  return data?.path
}

Assim, os arquivos de cada usuário ficam em sua própria “pasta”. A política RLS permite apenas operações em caminhos iniciados pelo ID do usuário, mantendo os arquivos dos demais fora de alcance.

Gerar uma URL de acesso assinada

O acesso direto a um arquivo de bucket privado retorna 404. É preciso gerar uma URL assinada:

// Gerar um link temporário de acesso (válido por 1 hora)
const { data, error } = await supabase.storage
  .from('avatars')
  .createSignedUrl('user123/avatar.jpg', 3600)

console.log(data?.signedUrl)  // URL completa com assinatura

Você escolhe a validade da assinatura. Um prazo muito longo não é seguro; um prazo curto demais prejudica a experiência. Em geral, de 1 a 4 horas é uma faixa adequada.

Se quiser tornar o arquivo completamente público sem alterar a configuração do bucket, use getPublicUrl:

const { data } = supabase.storage
  .from('public-assets')
  .getPublicUrl('logo.png')

// Esta URL não precisa de assinatura; qualquer pessoa pode acessá-la

Erros comuns na configuração das políticas

Estes são alguns dos problemas que já enfrentei:

  1. Esquecer a política INSERT: o usuário consegue entrar, mas não consegue enviar arquivos. A mensagem de erro é “new row violates row-level security policy”.

  2. Criar uma política permissiva demais: usar USING (true), por exemplo, permite que todos operem em todos os arquivos. Na prática, equivale a não configurar RLS.

  3. Usar uma estrutura de caminho inadequada: se o ID do usuário não estiver no primeiro nível, a extração feita por foldername não funcionará como esperado. Já cometi esse erro ao usar uploads/user123/file.jpg; o valor extraído era uploads, e a verificação da política falhava.

Ao configurar uma política, teste-a primeiro no editor SQL do painel. Confirme que a lógica está correta antes de aplicá-la ao ambiente de produção.

3. Mais desempenho: Smart CDN e transformação de imagens

Os arquivos já podem ser enviados e as permissões estão configuradas. A próxima questão é: como fazê-los carregar mais rápido?

Como funciona o Smart CDN

O Smart CDN do Supabase não é um CDN comum. Ele ajusta automaticamente a estratégia de cache com base na frequência de acesso: arquivos populares ficam armazenados por mais tempo, enquanto arquivos pouco acessados permanecem por períodos menores.

Segundo a documentação oficial, a invalidação do cache leva no máximo 60 segundos para ser sincronizada globalmente. Isso significa que, depois de atualizar um arquivo em Tóquio, os usuários de Nova York verão a versão mais recente em até 60 segundos. É muito mais rápido que os minutos ou até horas de um CDN tradicional.

No entanto, o Smart CDN é um recurso pago e exige o Pro Plan, de US$ 25 por mês. No Free Plan, os arquivos continuam acessíveis, mas sem a aceleração do CDN: eles são servidos diretamente pelos servidores do Supabase.

Parâmetros de transformação de imagens

Gosto bastante desse recurso: você não precisa implementar o redimensionamento nem o recorte, porque o Supabase faz tudo por meio de parâmetros na URL.

Parâmetros básicos:

?width=300&height=200  // Define as dimensões
?resize=contain        // Mantém a proporção, sem recortar
?resize=cover          // Preenche as dimensões e recorta o excedente
?quality=80            // Qualidade da imagem (1-100)
?format=webp           // Converte para WebP e reduz o tamanho

Você pode combiná-los:

const baseUrl = supabase.storage
  .from('avatars')
  .getPublicUrl('user123/avatar.jpg').data.publicUrl

// Gerar uma miniatura
const thumbnailUrl = `${baseUrl}?width=100&height=100&resize=cover`

Limites da transformação de imagens:

  • Faixa de dimensões: de 1 a 2500 pixels
  • Tamanho do arquivo original: no máximo 25 MB
  • Formatos compatíveis: JPEG, PNG, WebP, GIF e AVIF

Quando o limite é ultrapassado, o serviço retorna um erro. Certa vez, tentei redimensionar uma imagem original de 30 MB e ela foi rejeitada imediatamente.

Preço e franquia gratuita por projeto

A transformação de imagens é cobrada pela quantidade de transformações, não pelo espaço de armazenamento.

As primeiras 100 imagens transformadas por projeto a cada mês são gratuitas. Depois disso, o preço é de US$ 5 por 1.000 imagens.

Para um projeto pessoal ou uma equipe pequena, 100 imagens costumam ser suficientes. No meu próprio blog, transformo apenas algumas dezenas de avatares e imagens de artigos por mês. A menos que você esteja criando uma rede social de imagens parecida com o Instagram, esse custo provavelmente não será uma preocupação.

Integração com Next.js: Image Loader

Se você usa Next.js, pode configurar o Image Loader do Supabase para que o next/image aplique automaticamente as transformações:

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './supabase-image-loader.js',
  }
}

Em seguida, crie o arquivo do loader:

// supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
  const params = new URLSearchParams()
  params.set('width', width.toString())
  params.set('quality', (quality || 75).toString())
  params.set('format', 'webp')

  return `${src}?${params.toString()}`
}

Assim, quando você usar <Image src="..." width={300} /> no Next.js, os parâmetros de transformação serão acrescentados automaticamente.

A exigência do Pro Plan

O Smart CDN e a transformação de imagens, mencionados acima, exigem o Pro Plan. Quem usa o Free Plan tem acesso apenas aos recursos básicos de upload e download.

Vale a pena fazer o upgrade? Depende das necessidades do projeto. Se você armazena apenas alguns avatares, o Free Plan é suficiente. Mas, se precisa processar muitas imagens e otimizar o desempenho, a aceleração por CDN e a transformação de imagens do Pro Plan economizam bastante trabalho: você não precisa configurar seu próprio CDN nem criar um serviço de processamento de imagens.

Minha escolha é começar os testes com o Free Plan antes de colocar o projeto em produção e migrar para o Pro quando o tráfego se estabilizar. Afinal, US$ 25 por mês não é um valor desprezível.

4. Caso prático: configuração completa de um blog

Depois de tanta teoria, vale olhar um caso completo. Esta é a configuração do Storage no meu projeto de blog, do zero até o funcionamento.

Cenário: avatares de usuários e imagens de artigos

São necessários dois buckets:

  • avatars: avatares dos usuários; bucket privado em que cada usuário só pode operar no próprio avatar
  • post-images: imagens dos artigos; bucket privado em que os autores podem fazer upload e qualquer pessoa pode ler (com URL assinada)

Etapa 1: criar os buckets

No painel:

  1. Acesse Storage > New bucket, use o nome avatars e marque Private
  2. Crie post-images da mesma forma

Etapa 2: configurar as políticas RLS

Políticas do bucket avatars:

-- Permitir que qualquer pessoa leia os avatares (leitura pública)
CREATE POLICY "Anyone can view avatars"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');

-- O usuário só pode enviar e atualizar o próprio avatar
CREATE POLICY "Users manage own avatar"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);

-- O usuário só pode excluir o próprio avatar
CREATE POLICY "Users delete own avatar"
ON storage.objects FOR DELETE
USING (bucket_id = 'avatars' AND auth.uid()::text = (storage.foldername(name))[1]);

Políticas do bucket post-images:

-- Autores podem enviar imagens de artigos (supondo que tenham a função author)
CREATE POLICY "Authors can upload post images"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'post-images'
  AND auth.jwt() ->> 'role' = 'author'
);

-- Qualquer pessoa pode ler as imagens dos artigos
CREATE POLICY "Public read post images"
ON storage.objects FOR SELECT
USING (bucket_id = 'post-images');

Etapa 3: código de upload no frontend

Componente de upload do avatar:

async function handleAvatarUpload(file: File) {
  const user = await supabase.auth.getUser()
  if (!user.data.user) return alert('Faça login primeiro')

  // Caminho: ID do usuário/avatar.jpg (nome fixo; cada upload substitui o avatar anterior)
  const filePath = `${user.data.user.id}/avatar.jpg`

  const { error } = await supabase.storage
    .from('avatars')
    .upload(filePath, file, { upsert: true })

  if (!error) {
    // Obter a URL pública (pois a política SELECT permite a leitura por qualquer pessoa)
    const url = supabase.storage.from('avatars').getPublicUrl(filePath)
    setUserAvatar(url.data.publicUrl)
  }
}

Upload de imagens dos artigos:

async function handlePostImageUpload(file: File) {
  const filePath = `posts/${Date.now()}-${file.name}`

  const { data, error } = await supabase.storage
    .from('post-images')
    .upload(filePath, file)

  if (!error) {
    // Gerar uma URL assinada, válida por 24 horas
    const { data: urlData } = await supabase.storage
      .from('post-images')
      .createSignedUrl(filePath, 86400)

    insertImageToEditor(urlData?.signedUrl)
  }
}

Etapa 4: testar e validar

Antes de entrar em produção, verifique estes pontos essenciais:

  1. Um usuário não autenticado consegue ver as imagens dos artigos? (Deveria conseguir, pois a política SELECT permite.)
  2. Um usuário comum consegue enviar imagens de artigos? (Não deveria; apenas a função author tem permissão.)
  3. O usuário A consegue sobrescrever o avatar do usuário B? (Não deveria, graças ao isolamento pelo caminho.)

Teste cada ponto e confirme que as políticas estão corretas. Não quero repetir a lição que aprendi às três da manhã.

Conclusão

O Supabase Storage tem três pilares: upload, permissões e aceleração.

O upload é a parte mais simples e leva poucas linhas de código. Já a configuração de permissões merece atenção: uma política RLS não deve ser configurada uma vez e esquecida; ela precisa ser testada repetidamente de acordo com os cenários do produto. O CDN e a transformação de imagens são recursos adicionais disponíveis no Pro Plan, mas podem economizar bastante tempo de desenvolvimento.

Minha experiência é começar pelo upload básico e pelas permissões, garantindo primeiro que não exista risco de segurança. Adicione o CDN quando houver necessidade de desempenho e a transformação quando o produto exigir processamento de imagens. Avance passo a passo, sem tentar resolver tudo de uma vez.

Se você também usa o Supabase Storage, compartilhe os problemas que já encontrou. Provavelmente não fui a única pessoa a aprender essa lição às três da manhã.

Configuração completa do Supabase Storage

Processo prático completo, desde a criação do bucket e a configuração das permissões até a aceleração por CDN

⏱️ Estimated time: 30 min

  1. 1

    Step 1: Criar o bucket

    Crie um bucket privado no painel do Supabase:

    • Acesse Storage > New bucket
    • Informe um nome (como avatars)
    • Marque Private (a opção privada é recomendada por padrão)
    • Clique em Create bucket
  2. 2

    Step 2: Configurar a política RLS

    Configure o Row Level Security do bucket:

    • Acesse Storage > selecione o bucket > Policies
    • Clique em New Policy
    • Selecione o tipo de operação (SELECT/INSERT/UPDATE/DELETE)
    • Escreva a regra da política (como o isolamento por usuário)
    • Teste antes de aplicá-la ao ambiente de produção
  3. 3

    Step 3: Fazer upload de arquivos

    Use o SDK para enviar arquivos:

    • Defina a estrutura do caminho (como userId/filename)
    • Chame storage.from().upload()
    • Configure os parâmetros cacheControl e upsert
    • Trate os erros de upload e o caminho retornado
  4. 4

    Step 4: Configurar CDN e transformação de imagens (opcional)

    Após migrar para o Pro Plan, você poderá usar os recursos avançados:

    • O Smart CDN armazena automaticamente em cache os arquivos mais acessados
    • Parâmetros de transformação de imagens na URL (width/height/format)
    • Integração com o Image Loader do Next.js
    • Monitoramento da franquia gratuita (100 imagens/mês)

FAQ

Qual é a diferença entre um bucket público e um bucket privado?
Qualquer pessoa pode ler um bucket público, que é adequado para recursos estáticos públicos, como logotipos e avatares públicos. Um bucket privado exige autenticação para acesso, mas as permissões específicas são controladas pelas políticas RLS, o que oferece mais segurança.
Como configurar uma política RLS para isolar os usuários?
A ideia central é colocar o ID do usuário no primeiro nível do caminho:

• Defina o caminho de upload como userId/filename
• Na política, use auth.uid()::text = (storage.foldername(name))[1]
• Assim, o usuário só poderá operar em caminhos que começam com o próprio ID
Como compartilhar externamente um arquivo de um bucket privado?
Use createSignedUrl() para gerar uma URL temporária assinada e definir sua validade. Um prazo muito longo não é seguro, enquanto um prazo curto demais prejudica a experiência; em geral, de 1 a 4 horas é uma faixa adequada.
Quais são os limites para upload de arquivos?
O upload padrão aceita arquivos de até 5 GB. A experiência é melhor com o upload padrão para arquivos abaixo de 6 MB; acima de 6 MB, prefira o upload retomável pelo protocolo TUS, que pode continuar depois de uma interrupção na conexão.
Quais parâmetros e limites são aceitos na transformação de imagens?
Parâmetros aceitos:

• width/height: dimensões (faixa de 1 a 2500 pixels)
• resize: contain (mantém a proporção) ou cover (recorta para preencher)
• quality: qualidade (1-100)
• format: webp/jpeg/png/gif/avif

Limite: o arquivo original não pode ultrapassar 25 MB
O Smart CDN e a transformação de imagens são recursos pagos?
Sim. Eles exigem o Pro Plan (US$ 25/mês). O Free Plan oferece apenas os recursos básicos de upload e download. A transformação de imagens inclui uma franquia gratuita de 100 imagens por projeto por mês; depois disso, o preço é de US$ 5 por 1.000 imagens.

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

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog