DEV Community

Denis Augusto
Denis Augusto

Posted on

Você criou uma tabela de tokens pra proteger PDF. O Laravel já fazia isso.

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);
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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,
],
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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)