O que vamos fazer
PDF (contrato, comprovante, NF-e) vai pra bucket privado. O arquivo nunca é acessível por URL pública — quando o user pede pra baixar, o server gera uma URL assinada que expira em 1 hora. Depois do TTL, link morre. Sem replay attacks, sem vazamento por URL compartilhada.
Fluxo:
- Bucket
documentosprivado, com policy "só dono lê". - Upload pelo client autenticado (RLS valida).
- Quando user clica "baixar": Next.js server action chama
createSignedUrl(path, 3600)com service-role. - Server valida que
pathpertence ao user antes de assinar. - URL volta pro client, abre em nova aba.
Pré-requisitos
- Projeto SuperDB com Auth configurada.
- Variável de ambiente
SUPERDB_SERVICE_ROLE_KEYdisponível só no server. - Bucket
documentoscriado privado (não marcar "Public").
Service-role nunca no client: a chave service_role dá poderes de admin (bypassa RLS). Ela vive em variável de ambiente do server. Nunca em NEXT_PUBLIC_*, nunca em código que vai pro bundle.
Passo a passo
1. Criar bucket privado
Studio → Storage → New bucket. Nome documentos. NÃO marque "Public bucket". File size limit 25 MB (PDF cabe folgado). Allowed MIME: application/pdf.
2. Policy: dono só vê o próprio
-- Upload: só no próprio diretório
create policy doc_own_insert
on storage.objects for insert to authenticated
with check (
bucket_id = 'documentos'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- Leitura via SDK autenticado: só o próprio (a signed URL bypassa isso)
create policy doc_own_select
on storage.objects for select to authenticated
using (
bucket_id = 'documentos'
and (storage.foldername(name))[1] = auth.uid()::text
);
3. Upload no client
Para upload, use db.storage.from('documentos').upload(...) com o client autenticado — o SDK minta o token de storage sozinho e a policy de INSERT valida o path.
Nota: @superdb/client é drop-in do supabase-js — já traz .storage e .from(). O SDK cuida do token de storage; não aponte @supabase/storage-js com a anon key direto no storage.superdb.com.br (a anon key é ES256 e o storage-api valida HS256 → 401).
'use client'
import { db } from '@/lib/superdb'
import { useUser } from '@/lib/auth'
export function UploadContrato() {
const user = useUser()
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file || !user) return
const path = `${user.id}/${crypto.randomUUID()}.pdf`
// Upload: o SDK minta o token de storage sozinho
const { error } = await db.storage.from('documentos')
.upload(path, file, { contentType: 'application/pdf' })
if (error) return alert('Upload falhou')
// Persistir referência — .from().insert() funciona (drop-in supabase-js)
await db.from('arquivos').insert({
user_id: user.id, bucket: 'documentos', path, nome: file.name,
})
}
return <input type="file" accept="application/pdf" onChange={onFile} />
}
4. Server action: gerar signed URL
O segredo é gerar a signed URL no server e validar que o path pedido pertence ao usuário logado. Caso contrário, qualquer user logado conseguia gerar URL pro PDF de outro.
'use server'
import { createClient } from '@superdb/client'
import { getServerUser } from '@/lib/auth-server'
// Client server-side com service-role — bypassa RLS pra assinar
const admin = createClient(
'https://auth.superdb.com.br',
process.env.SUPERDB_SERVICE_ROLE_KEY!,
{ project: '<slug>' }
)
export async function getDownloadUrl(path: string) {
const user = await getServerUser()
if (!user) throw new Error('Não autenticado')
// CRÍTICO: validar que o path é do user logado
if (!path.startsWith(`${user.id}/`)) {
throw new Error('Acesso negado')
}
// O SDK minta o token de storage e assina a URL
const { data, error } = await admin.storage
.from('documentos')
.createSignedUrl(path, 3600) // 1 hora
if (error) throw new Error('Erro ao gerar signed URL')
return data.signedUrl
}
5. Botão de download no client
'use client'
import { getDownloadUrl } from '@/app/actions/download'
export function DownloadBtn({ path, nome }: { path: string; nome: string }) {
async function baixar() {
const url = await getDownloadUrl(path)
window.open(url, '_blank')
}
return <button onClick={baixar}>Baixar {nome}</button>
}
Dica: precisa baixar vários de uma vez (boleto + NF-e + recibo)? Use createSignedUrls(paths, 3600) em batch — 1 round-trip pro server, N URLs assinadas.
Resultado
O que você tem:
- PDFs guardados em bucket privado — invisíveis sem assinatura.
- Link de download válido por 1 hora, depois expira sozinho.
- Cada usuário só baixa os PDFs dele — validação no server, não confia no client.
- Funciona pra contratos, comprovantes, NF-e, recibos, holerites.
Variações
TTL customizado
TTL em segundos: 3600 = 1h, 86400 = 24h, 604800 = 7d. Pra "compartilhar contrato com cliente externo": gere com TTL maior e mande por email. Mas não passe de 7d — se precisar de mais, o caso é outro (ver erros comuns).
Forçar download (Content-Disposition)
Por padrão o navegador tenta abrir o PDF inline. Pra forçar "Salvar como":
const { data } = await admin.storage
.from('documentos')
.createSignedUrl(path, 3600, {
download: 'contrato-2026.pdf', // força Content-Disposition
})
Audit log de quem acessou
Antes de chamar createSignedUrl, insira numa tabela pdf_access_log:
create table pdf_access_log (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
path text not null,
ip text,
user_agent text,
acessado_em timestamptz default now()
);
Erros comuns
Chamar createSignedUrl no client: só funciona se o user tem policy SELECT no objeto — o que vaza permissão. Sempre gere no server com service-role + validação manual de ownership.
TTL muito longo: createSignedUrl(path, 90 * 86400) vira link público de fato — quem receber pode redistribuir, e o link continua funcionando por 90 dias. Pra "compartilhar permanente", use bucket público com policy de leitura ou implemente reverse proxy.
Não validar ownership no server: se o server só repassa path pro createSignedUrl sem checar que começa com auth.uid(), qualquer user logado descobre o ID de outro (vaza em URL, log, etc) e gera URL pro PDF alheio. Sempre path.startsWith(user.id + '/').
Bucket público em vez de privado: se marcou "Public bucket" por engano, todo URL /object/public/... funciona sem assinatura — vaza tudo. Veja em Studio → bucket → Configuration.