Receber um webhook é simples: você expõe um endpoint, recebe um POST e interpreta o JSON.
O problema começa quando esse endpoint confia em qualquer payload que chega.
Se uma aplicação executa ações a partir de webhooks — atualiza dados, inicia deploys, envia notificações ou dispara automações — ela precisa confirmar duas coisas antes de processar o evento:
- o payload foi assinado com o secret compartilhado com o GitHub;
- o conteúdo recebido não foi modificado no caminho.
Neste artigo, vamos implementar essa verificação com HMAC SHA-256 em PHP e Node.js.
Os exemplos completos e testados estão no repositório github-webhook-security-guide.
Como a assinatura funciona
Ao enviar uma entrega, o GitHub calcula um HMAC usando o corpo original da requisição, o secret configurado no webhook e o algoritmo SHA-256.
O resultado é enviado no header:
X-Hub-Signature-256: sha256=<assinatura hexadecimal>
Enter fullscreen mode Exit fullscreen mode
Seu endpoint precisa calcular a assinatura esperada a partir do corpo bruto recebido e compará-la com o valor do header.
Esse detalhe é importante: não valide a assinatura depois de converter o JSON em objeto e serializá-lo novamente. Qualquer mudança nos bytes — espaços, quebras de linha, Unicode ou ordem de serialização — muda o HMAC.
Checklist mínimo
- Leia o corpo bruto antes de interpretar o JSON.
- Rejeite secret vazio.
- Valide o formato de
X-Hub-Signature-256. - Calcule o HMAC com SHA-256.
- Compare em tempo constante.
- Rejeite assinaturas ausentes ou inválidas.
- Só então interprete e processe o evento.
Implementação em PHP 8+
Em PHP, podemos usar hash_hmac() para gerar a assinatura e hash_equals() para realizar a comparação segura:
<?php
declare(strict_types=1);
function verifyGitHubWebhook(
string $payload,
string $signatureHeader,
string $secret
): bool {
if (
$secret === '' ||
!preg_match('/^sha256=[a-f0-9]{64}$/', $signatureHeader)
) {
return false;
}
$expected = 'sha256=' . hash_hmac(
'sha256',
$payload,
$secret
);
return hash_equals($expected, $signatureHeader);
}
Enter fullscreen mode Exit fullscreen mode
O formato é verificado antes do cálculo. Isso elimina headers ausentes ou malformados e garante que a comparação receba valores com a estrutura esperada.
Um endpoint genérico poderia usar a função assim:
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$secret = getenv('GITHUB_WEBHOOK_SECRET') ?: '';
if (!verifyGitHubWebhook($payload, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
Enter fullscreen mode Exit fullscreen mode
O secret vem do ambiente, nunca do repositório.
Implementação em Node.js 20+
No Node.js, usamos createHmac() e timingSafeEqual() do módulo nativo node:crypto:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyGitHubWebhook(payload, signatureHeader, secret) {
if (!secret || !/^sha256=[a-f0-9]{64}$/.test(signatureHeader ?? "")) {
return false;
}
const expected = `sha256=${createHmac("sha256", secret)
.update(payload)
.digest("hex")}`;
const receivedBuffer = Buffer.from(signatureHeader, "utf8");
const expectedBuffer = Buffer.from(expected, "utf8");
return (
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer)
);
}
Enter fullscreen mode Exit fullscreen mode
A verificação do tamanho antes de timingSafeEqual() é obrigatória: a função lança uma exceção quando os buffers têm comprimentos diferentes.
Em frameworks como Express, Fastify ou NestJS, preserve o corpo bruto. Se o middleware de JSON consumir ou transformar o body antes da validação, a assinatura calculada não será a mesma.
Por que não usar uma comparação comum?
Pode parecer suficiente escrever expected === signatureHeader. Mas comparações comuns podem encerrar o trabalho assim que encontram o primeiro caractere diferente. Em determinados cenários, a variação no tempo de resposta pode revelar informações sobre a assinatura esperada.
hash_equals() e timingSafeEqual() foram projetadas para reduzir esse risco por meio de comparação em tempo constante.
A assinatura é apenas a primeira camada
Uma assinatura válida prova que o payload foi assinado com o secret compartilhado. Ela não transforma todo evento em uma ação autorizada para o seu negócio.
Idempotência
Armazene o header X-GitHub-Delivery. Se a mesma entrega chegar novamente, responda de maneira idempotente em vez de repetir efeitos colaterais.
Eventos permitidos
Leia X-GitHub-Event e aceite somente os eventos necessários. Um endpoint criado para push não deve processar qualquer tipo de entrega.
Limites e filas
Limite o tamanho do body, responda rapidamente e envie trabalho pesado para uma fila. O endpoint do webhook não deve executar todo o processamento de forma síncrona.
Logs e retenção
Nunca registre o secret. Evite armazenar payloads completos sem necessidade e defina uma política de retenção para dados que possam conter informações sensíveis.
Autorização de negócio
Mesmo depois de validar o GitHub, confirme se aquele repositório, organização, instalação ou evento pode executar a operação solicitada.
Testando a implementação
O GitHub fornece um vetor público de teste:
secret: It's a Secret to Everybody
payload: Hello, World!
assinatura esperada:
sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17
Enter fullscreen mode Exit fullscreen mode
Use esse valor em testes automatizados e acrescente casos para assinatura correta, assinatura ausente, prefixo inválido, tamanho incorreto, payload modificado e secret vazio.
O repositório do guia inclui exemplos independentes de framework e testes executáveis para PHP e Node.js.
Fluxo recomendado em produção
- Receber o POST via HTTPS.
- Preservar o corpo bruto.
- Validar a assinatura.
- Verificar duplicidade por
X-GitHub-Delivery. - Filtrar o tipo em
X-GitHub-Event. - Persistir um envelope mínimo.
- Enviar o processamento para uma fila.
- Responder rapidamente ao GitHub.
A validação HMAC é pequena em quantidade de código, mas define a fronteira de confiança de todo o sistema.
Se seu produto recebe webhooks, não trate o endpoint como uma simples rota de entrada. Trate-o como uma interface pública exposta a dados não confiáveis.
Como você trata idempotência e reprocessamento de webhooks nos seus projetos?
Enter fullscreen mode Exit fullscreen mode
답글 남기기