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 HTTP direto contra storage.superdb.com.br com o token do usuário autenticado — a policy de INSERT valida o path.
Não existe @superdb/superdb-js. @superdb/auth-js é apenas auth — sem .storage ou .from(). Use HTTP direto ou @supabase/supabase-js com storage.superdb.com.br e api.superdb.com.br.
'use client'
import { useUser } from '@/lib/auth'
const ANON_KEY = process.env.NEXT_PUBLIC_SUPERDB_ANON_KEY!
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 via HTTP direto — storage.superdb.com.br
const storageRes = await fetch(
`https://storage.superdb.com.br/object/documentos/${path}`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${ANON_KEY}`, 'apikey': ANON_KEY,
'Content-Type': 'application/pdf' },
body: file,
}
)
if (!storageRes.ok) return alert('Upload falhou')
// Persistir referência — api.superdb.com.br (sem /rest/v1/)
await fetch('https://api.superdb.com.br/arquivos', {
method: 'POST',
headers: { 'Authorization': `Bearer ${ANON_KEY}`, 'apikey': ANON_KEY,
'Content-Type': 'application/json', 'Prefer': 'return=minimal' },
body: JSON.stringify({ 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 { getServerUser } from '@/lib/auth-server'
const SERVICE_KEY = process.env.SUPERDB_SERVICE_ROLE_KEY!
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')
}
// Signed URL via HTTP direto — storage.superdb.com.br
const res = await fetch(
`https://storage.superdb.com.br/object/sign/documentos/${path}`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${SERVICE_KEY}`, 'apikey': SERVICE_KEY,
'Content-Type': 'application/json' },
body: JSON.stringify({ expiresIn: 3600 }), // 1 hora
}
)
if (!res.ok) throw new Error('Erro ao gerar signed URL')
const { signedURL } = await res.json()
return `https://storage.superdb.com.br${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.