Como validar webhooks do GitHub com HMAC em PHP e Node.js

작성자

카테고리:

← 피드로
DEV Community · Asllan Maciel · 2026-08-11 개발(SW)

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:

  1. o payload foi assinado com o secret compartilhado com o GitHub;
  2. 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

  1. Receber o POST via HTTPS.
  2. Preservar o corpo bruto.
  3. Validar a assinatura.
  4. Verificar duplicidade por X-GitHub-Delivery.
  5. Filtrar o tipo em X-GitHub-Event.
  6. Persistir um envelope mínimo.
  7. Enviar o processamento para uma fila.
  8. 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

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다