O contrato do cliente tá numa URL que qualquer um adivinha
A tarefa parecia simples: o cliente precisa baixar a nota fiscal dele.
Você salvou em storage/app/public/notas/, rodou php artisan storage:link, mandou o link e foi feliz. https://app.com/storage/notas/nota-1042.pdf.
Semanas depois cai a ficha. Aquele arquivo está aberto na internet. Sem login, sem nada. E o nome é sequencial: quem baixou a nota-1042.pdf só precisa de curiosidade e cinco segundos pra tentar a 1041. E a 1040.
Então você faz a coisa certa: tira do disco público e cria um sistema pra controlar acesso. Tabela download_tokens, model, geração de UUID, coluna expires_at, controller que valida, e um comando no scheduler pra limpar os vencidos.
Sessenta linhas depois, funciona. E aí alguém comenta no PR: "por que você não usou uma URL assinada?"
O sistema que você não precisava construir
// ❌ migration + model + controller + command. tudo isso pra um PDF.
Schema::create('download_tokens', function (Blueprint $table) {
$table->id();
$table->uuid('token')->unique();
$table->string('path');
$table->foreignId('user_id');
$table->timestamp('expires_at');
$table->timestamps();
});
public function gerarLink(NotaFiscal $nota): string
{
$token = DownloadToken::create([
'token' => Str::uuid(),
'path' => $nota->arquivo_path,
'user_id' => auth()->id(),
'expires_at' => now()->addMinutes(10),
]);
return route('download', $token->token);
}
Não tem nada de errado tecnicamente. O problema é o custo: mais uma tabela crescendo pra sempre, mais um comando no scheduler, mais um caminho pra testar. E você vai manter isso enquanto o projeto existir.
O Laravel resolve o mesmo problema com uma assinatura criptográfica na própria URL. Sem estado, sem tabela, sem limpeza.
Como uma URL assinada funciona
A ideia é bonita de simples: o Laravel monta a URL com os parâmetros que você quer, calcula um hash disso tudo usando a APP_KEY e cola o hash no final.
/notas/1042/download?expires=1755388800&signature=8f3a9c...
Quando o request chega, ele recalcula o hash e compara. Mexeu em qualquer coisa — o ID, a data de expiração, uma vírgula — a assinatura não fecha e o acesso morre com 403.
Ou seja: o "token" é a própria URL. Não tem o que guardar, porque a validade está embutida nela.
Duas linhas, dois cenários
Cenário 1: o arquivo tá no S3 (ou em qualquer disco que gere URL temporária).
Aqui é literalmente uma linha, e o download nem passa pelo seu servidor:
$url = Storage::disk('s3')->temporaryUrl(
$nota->arquivo_path,
now()->addMinutes(5)
);
Cinco minutos depois o link morre. O S3 se recusa a servir. Zero código seu envolvido.
Se você usa disco local, isso também funciona desde o Laravel 11 — só precisa avisar no config/filesystems.php:
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true, // libera o temporaryUrl no disco local
'throw' => false,
],
Cenário 2: você quer uma rota sua, pra registrar o acesso ou aplicar regra.
// gerando o link
return URL::temporarySignedRoute(
'notas.download',
now()->addMinutes(10),
['nota' => $nota->id]
);
// a rota. o middleware 'signed' faz toda a validação.
Route::get('/notas/{nota}/download', function (NotaFiscal $nota) {
return Storage::download($nota->arquivo_path, "nota-{$nota->numero}.pdf");
})->name('notas.download')->middleware('signed');
O signed confere a assinatura e a expiração antes do seu código rodar. Se estiver inválido, 403 e pronto — você não escreve um if.
Como usar na prática
Nota fiscal enviada por e-mail: gere com validade de 7 dias. O cliente clica direto do e-mail, sem login, e o link não serve pra mais ninguém depois.
Cancelar inscrição: o caso clássico. URL::signedRoute('unsubscribe', ['user' => $user->id]) sem expiração — o cara clica dois meses depois e funciona, mas ninguém consegue descadastrar outra pessoa trocando o ID na URL.
Convite de cadastro: link com validade de 48 horas que já leva o e-mail assinado dentro. Se o convidado editar o e-mail pra outro, a assinatura quebra.
A pegadinha: assinatura não é autorização
Essa é a confusão que vale gravar.
Uma URL assinada garante que ninguém adulterou o link. Ela não garante que quem está clicando é o dono do arquivo. Se o link vazar no WhatsApp, quem tiver o link entra.
Pra dado sensível, as duas coisas convivem:
Route::get('/notas/{nota}/download', function (NotaFiscal $nota) {
Gate::authorize('view', $nota); // e ainda precisa ser o dono
return Storage::download($nota->arquivo_path);
})->middleware(['signed', 'auth']);
Assinatura para quem está fora do login (e-mail, webhook, convite). Policy para quem está dentro. Expiração curta em tudo que for confidencial.
E o outro tropeço, o mais comum de todos: storage:link com disco public é público de verdade. Não é "público pro usuário logado", é público pro Google. Se o arquivo tem dono, ele não mora ali — mora no disco privado, e sai por uma rota.
Bônus: dois detalhes que economizam uma tarde
Trocar a APP_KEY invalida todas as assinaturas em circulação. É até desejável (é o seu "logout de emergência" de links), mas se você rotacionar a chave num dia de disparo de e-mail, prepare o suporte.
Atrás de proxy, cuide da URL. Se APP_URL ou os TrustProxies estiverem tortos, o Laravel calcula a assinatura sobre um host diferente do que o usuário acessou e tudo dá 403. O sintoma é sempre o mesmo: funciona local, quebra em produção.
Antes de você fechar a aba
A lição aqui é maior que URL assinada: antes de criar tabela e comando de limpeza pra resolver algo genérico, dá uma busca na doc. "Expirar um link" é um problema que todo mundo tem — as chances de já vir resolvido são altas.
E o código que você não escreve é o único que nunca tem bug.
Agora fala a verdade: você tem arquivo de cliente numa pasta pública agora? Se der aquele friozinho na barriga, abre o storage/app/public antes de responder. Eu já achei um monte de contratos assinados lá dentro — em projeto que não era meu, felizmente. 😅
Top comments (0)