Validar assinatura de webhook sem se enganar
Equipe AltiviaCloud · 5 de setembro de 2026 · 4 min de leitura
Comparar com !== vaza o segredo pelo tempo de resposta, e reserializar o corpo antes do HMAC faz a assinatura nunca bater. Os dois modelos, os erros de cada um e três testes que provam que está de pé.
Um endpoint de webhook é uma URL pública que executa lógica de negócio. Se ele confia no corpo da requisição sem verificar quem enviou, qualquer pessoa que descobrir o endereço pode creditar saldo, marcar pedido como pago ou disparar o que estiver ali dentro.
Validar assinatura é o que separa um webhook de um formulário aberto.
Os dois modelos que você vai encontrar
Token compartilhado. O provedor manda um cabeçalho com um valor fixo, que você cadastrou no painel dele. Você compara com o que tem guardado. Simples, e é o modelo do Asaas e de vários outros.
HMAC do corpo. O provedor calcula um hash do corpo da requisição usando um segredo combinado, e manda o resultado num cabeçalho. Você recalcula e compara. Mais forte, porque a assinatura muda a cada requisição e prova que aquele corpo não foi alterado.
Saber qual dos dois você está usando é o primeiro passo, e a fonte de um erro comum: aplicar a lógica de um no outro.
O erro que ninguém percebe
// errado
if (headerRecebido !== segredo) return unauthorized();
Comparação de string com !== sai no primeiro caractere diferente. O tempo de resposta vaza informação sobre quantos caracteres iniciais estão corretos, e isso é suficiente para descobrir o segredo caractere a caractere, com requisições suficientes.
O certo é comparação em tempo constante:
import { timingSafeEqual } from "node:crypto";
function assinaturaConfere(recebido: string, esperado: string) {
const a = Buffer.from(recebido);
const b = Buffer.from(esperado);
// timingSafeEqual lança se os tamanhos diferem — cheque antes,
// e devolva false em vez de deixar estourar.
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
O detalhe do tamanho é o que mais derruba implementação: timingSafeEqual exige buffers de igual comprimento e lança exceção se não forem. Um webhook malformado viraria erro 500 em vez de 401.
Se for HMAC, assine o corpo cru
Este é o erro sutil que custa uma tarde:
// errado: assina o objeto reserializado
const corpo = await req.json();
const hash = hmac(JSON.stringify(corpo), segredo);
JSON.parse seguido de JSON.stringify não devolve os mesmos bytes. Espaçamento, ordem de chaves e escapes podem mudar — e o hash muda junto. A assinatura nunca vai bater, e você vai passar horas conferindo o segredo.
Use o texto exato que chegou:
const cru = await req.text();
const esperado = createHmac("sha256", segredo).update(cru).digest("hex");
if (!assinaturaConfere(req.headers.get("x-signature") ?? "", esperado)) {
return new Response("Unauthorized", { status: 401 });
}
const corpo = JSON.parse(cru); // só depois de validar
Repare na ordem: validar primeiro, interpretar depois. Fazer JSON.parse antes da validação é executar parser sobre entrada não confiável sem necessidade.
Cuidado com HMAC de valor constante
Existe um padrão que parece seguro e não é:
const esperado = createHmac("sha256", segredo).update("nome-do-provedor").digest("hex");
Isso deriva um valor fixo a partir do segredo. Não assina nada — o corpo pode ser qualquer coisa. Na prática é um token compartilhado com passos extras, e com um risco a mais: quem configurou o painel do provedor precisa ter cadastrado exatamente esse hash, não o segredo. Se cadastrar o segredo puro, todos os webhooks passam a ser rejeitados com 401, e a falha é silenciosa — do lado do provedor, retentativas; do seu, nada acontecendo.
Se o provedor usa token compartilhado, compare o token direto com timingSafeEqual. Se usa HMAC, assine o corpo. Não invente um meio-termo.
O resto da lista
Rejeite requisição antiga. Se o provedor manda timestamp, recuse o que passar de alguns minutos. Sem isso, uma requisição capturada pode ser reenviada indefinidamente.
Nunca compare em log. Registrar a assinatura recebida junto com a esperada coloca o segredo derivado no seu sistema de logs — que costuma ter menos controle de acesso que a variável de ambiente de onde o segredo veio.
O segredo é chave de produção. Fora do repositório, na variável de ambiente, tratado como qualquer outra credencial.
Valide a assinatura antes da idempotência. Não faz sentido gastar consulta ao banco decidindo se já processou um evento que talvez nem seja seu. Assinatura primeiro, depois a garantia de que o mesmo evento não credita duas vezes.
Como testar que está de pé
Três requisições:
- Corpo válido, assinatura válida → deve processar.
- Corpo válido, assinatura trocada por lixo → deve devolver 401.
- Corpo alterado depois de assinado, assinatura original → deve devolver 401.
O terceiro é o que prova que você está assinando o corpo, e não só conferindo um token. Se ele passar, sua validação não protege contra adulteração — protege apenas contra quem não conhece a URL.
Os webhooks da AltiviaCloud usam o modelo com HMAC e timestamp descrito aqui; o passo a passo está na documentação de webhooks, e o produto, em números virtuais.