Skip to content

URLs Assinadas

URLs assinadas fornecem acesso temporário e com escopo de recurso a recursos protegidos sem exigir que o chamador se autentique com uma conta. O padrão é usado para downloads de arquivos, slots de upload pré-assinados e qualquer caso em que você precise compartilhar acesso temporário com um terceiro.

Conceito Central

Uma URL assinada contém tudo o que é necessário para autorizar o acesso: o ID do recurso, o tempo de expiração e uma assinatura HMAC que prova que a URL foi gerada por um servidor confiável. O servidor precisa apenas de sua chave secreta para verificar — nenhuma consulta ao banco de dados é necessária.

Formato do Token

base64url({resource_id}|{expires_at}|{hmac-sha256(resource_id|expires_at, secret)})

O HMAC cobre resource_id|expires_at juntos. Alterar qualquer uma das partes invalida a assinatura. Isso vincula o token a exatamente um recurso e uma janela de expiração.

Implementação do Signer

php
final readonly class HmacSigner
{
    private const string ALGO = 'sha256';

    public function __construct(
        private string $secret,
    ) {}

    public function sign(int $resourceId, string $expiresAt): string
    {
        $payload = $resourceId . '|' . $expiresAt;
        $mac     = hash_hmac(self::ALGO, $payload, $this->secret);

        return $this->base64UrlEncode($payload . '|' . $mac);
    }

    public function verify(string $token, string $now): ?int
    {
        $decoded = $this->base64UrlDecode($token);
        if ($decoded === null) {
            return null;
        }

        $parts = explode('|', $decoded, 3);
        if (count($parts) !== 3) {
            return null;
        }

        [$resourceId, $expiresAt, $storedMac] = $parts;

        $expectedMac = hash_hmac(self::ALGO, $resourceId . '|' . $expiresAt, $this->secret);

        // hash_equals() é obrigatório — usar === vaza informações de timing
        if (!hash_equals($expectedMac, $storedMac)) {
            return null;
        }

        if ($expiresAt < $now) {
            return null;
        }

        return (int) $resourceId;
    }
}

hash_equals() é inegociável. Uma comparação de igualdade de string sai no primeiro mismatch, vazando quantos caracteres do HMAC correspondem. Um atacante pode explorar isso para forjar assinaturas byte a byte. hash_equals() sempre compara todos os caracteres.

410 Gone vs 401 Unauthorized para Tokens Expirados

Usuários se beneficiam de saber se seu link expirou (e devem solicitar um novo) em vez de se o link nunca foi válido. O signer verifica o HMAC primeiro, depois a expiração. Para distingui-los na resposta HTTP:

php
$resourceId = $this->signer->verify($token, $now);

if ($resourceId === null) {
    // Extrair expiração sem verificação de HMAC
    $expiresAt = $this->signer->extractExpiresAt($token);
    if ($expiresAt !== null && $expiresAt < $now) {
        return $problems->create($request, 'gone', 'Este link expirou.', 410, '');
    }
    return $problems->create($request, 'unauthorized', 'Token inválido ou expirado.', 401, '');
}

extractExpiresAt() apenas decodifica base64 e divide no | — ele NÃO verifica o HMAC. Isso é seguro porque:

  1. A expiração não é um segredo (é visível na URL assinada de qualquer forma).
  2. Um atacante não pode forjar um token válido com uma expiração manipulada porque verify() irá rejeitá-lo.
  3. A resposta 410 não fornece informações que ajudem a forjar tokens.

NÃO exponha mensagens de erro diferentes para "HMAC não confere" vs "expiração passada" — isso permitiria que um atacante construísse assinaturas válidas para valores de expiração arbitrários primeiro, e depois os usasse para sondar o timing.

Gerando URLs Assinadas

php
// POST /files/{id}/sign
$expiresAt = (new \DateTimeImmutable())
    ->add(new \DateInterval("PT{$ttlSeconds}S"))
    ->format('Y-m-d H:i:s');

$token = $this->signer->sign($file->id, $expiresAt);

return $json->create([
    'token'       => $token,
    'expires_at'  => $expiresAt,
    'ttl_seconds' => $ttlSeconds,
    'url'         => '/download?token=' . urlencode($token),
]);

Sempre aplique urlencode() ao token antes de incorporar em URLs — caracteres base64url são seguros para URL, mas o preenchimento = (se presente) não é, e o separador | no payload decodificado não deve aparecer na forma codificada.

Gerenciamento de Chave Secreta

  • Injete o secret a partir de uma variável de ambiente — nunca o codifique diretamente.
  • Use pelo menos 32 bytes de dados aleatórios (random_bytes(32) → hex ou base64).
  • Para rotação de secret, suporte a verificação contra múltiplos secrets simultaneamente (tente cada um até que um tenha sucesso), depois elimine gradualmente o secret antigo.
php
// Suporte a múltiplos secrets durante rotação
public function verifyWithRotation(string $token, string $now, array $secrets): ?int
{
    foreach ($secrets as $secret) {
        $signer = new HmacSigner($secret);
        $id = $signer->verify($token, $now);
        if ($id !== null) {
            return $id;
        }
    }
    return null;
}

URLs Assinadas Stateless vs Stateful

Este padrão é stateless — o servidor não rastreia tokens emitidos. Esta é a principal vantagem (sem consulta ao banco de dados em cada download), mas significa:

  • Você não pode revogar uma URL assinada antes que ela expire.
  • Se o secret for rotacionado, todos os tokens emitidos anteriormente são invalidados imediatamente.

Para tokens revogáveis, mantenha uma tabela de blocklist (revoked_tokens) e verifique-a durante a verificação. Isso troca o benefício stateless pela revogabilidade.

O Que NÃO Fazer

AntipadrãoRisco
Usar === ou strcmp() para comparação de HMACAtaque de timing — permite forjar assinaturas
Assinar apenas resource_id sem expiraçãoTokens são permanentes — não podem expirar
Assinar apenas expires_at sem resource_idUm token concede acesso a todos os recursos
Usar a expiração para distinguir "adulterado" de "expirado"Permite ataque de oráculo no HMAC
Incorporar a chave bruta no tokenDerrota o propósito — o token deve ser opaco
TTLs longos (dias/semanas)Aumenta a janela de exposição se o token vazar

Publicado sob a licença MIT.