Como validar a assinatura de um webhook com HMAC

Muitos serviços assinam o webhook com o HMAC-SHA256 do corpo da requisição e enviam a assinatura em um cabeçalho. Validar é recalcular o HMAC com o segredo compartilhado e comparar em tempo constante. Este guia mostra como fazer isso em Node.js, Python e PHP, e quais erros fazem a assinatura nunca bater.

Por Leonardo Gaertner · Publicado em

Como a assinatura funciona

O serviço que envia o webhook e o seu sistema compartilham um segredo. A cada envio, o serviço calcula o HMAC-SHA256 (um hash SHA-256 combinado com o segredo) do corpo da requisição com esse segredo e coloca o resultado em um cabeçalho. O GitHub, por exemplo, usa X-Hub-Signature-256, no formato sha256= seguido do valor em hexadecimal. Você repete o cálculo e compara: se bateu, a mensagem veio de quem tem o segredo e não foi alterada no caminho.

Um exemplo da documentação do GitHub: com o segredo It's a Secret to Everybody e o corpo Hello, World!, a assinatura é sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. Você pode reproduzir isso no gerador de HMAC.

Alguns provedores, como o Stripe, acrescentam um carimbo de tempo ao texto assinado, para dificultar o reenvio de uma requisição antiga. Confira a documentação de cada serviço para o formato exato.

Use o corpo original da requisição

O erro mais comum é calcular o HMAC sobre um JSON que o framework já transformou em objeto e reescreveu. Mudar um espaço, a ordem das chaves ou a codificação muda o resultado. Leia o corpo bruto (os bytes) antes de qualquer processamento e calcule sobre ele.

Verificar em Node.js

JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto';

// corpo: o corpo ORIGINAL da requisição (Buffer ou texto, sem reformatar)
// cabecalho: o valor do cabeçalho de assinatura, por exemplo "sha256=ab12..."
function assinaturaValida(corpo, segredo, cabecalho) {
  const esperada = 'sha256=' + createHmac('sha256', segredo).update(corpo).digest('hex');
  const a = Buffer.from(cabecalho ?? '');
  const b = Buffer.from(esperada);
  return a.length === b.length && timingSafeEqual(a, b);
}

No Express, use express.raw para receber o corpo original e chame a função antes de tratar o evento:

JavaScript
import express from 'express';

const app = express();

// express.raw entrega o corpo original em req.body (um Buffer), sem transformar em objeto
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!assinaturaValida(req.body, process.env.WEBHOOK_SECRET, req.get('X-Hub-Signature-256'))) {
    return res.status(401).send('Assinatura inválida');
  }

  const evento = JSON.parse(req.body.toString('utf8'));
  // ... trate o evento aqui
  res.sendStatus(204);
});

Verificar em Python

Em Python, hmac.compare_digest faz a comparação em tempo constante:

Python
import hashlib
import hmac


def assinatura_valida(corpo: bytes, segredo: str, cabecalho: str | None) -> bool:
    esperada = "sha256=" + hmac.new(segredo.encode(), corpo, hashlib.sha256).hexdigest()
    return hmac.compare_digest(esperada.encode(), (cabecalho or "").encode())

Verificar em PHP

Em PHP, use hash_hmac para calcular e hash_equals para comparar:

PHP
<?php
// $corpo: o corpo ORIGINAL da requisição, por exemplo file_get_contents('php://input')
// $cabecalho: por exemplo $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? null
function assinaturaValida(string $corpo, string $segredo, ?string $cabecalho): bool
{
    $esperada = 'sha256=' . hash_hmac('sha256', $corpo, $segredo);
    return hash_equals($esperada, $cabecalho ?? '');
}

Erros que fazem a assinatura não bater

  • Calcular sobre o corpo reformatado: espaços, ordem das chaves ou quebras de linha diferentes mudam o HMAC.
  • Usar o segredo no formato errado: um segredo entregue em Base64 ou hexadecimal, tratado como texto, gera outro HMAC. Para conferir um valor em Base64, use o codificador de Base64.
  • Errar a codificação do texto: o cálculo deve ser feito sobre os bytes do corpo, em UTF-8.
  • Comparar com ==: use uma comparação em tempo constante, como nas funções acima.
  • Errar o prefixo: alguns serviços enviam só o hexadecimal, outros colocam sha256= na frente.
  • Ignorar o carimbo de tempo quando o provedor o exige: nesse caso, a assinatura não cobre só o corpo.

Como depurar uma assinatura que não bate

Cole o corpo e o segredo no gerador de HMAC, escolha o algoritmo e cole a assinatura recebida no último campo: a ferramenta diz se ela confere. Se não conferir, troque o formato da chave (texto, hexadecimal ou Base64) e revise o corpo byte a byte.

Achou um erro neste guia? Avise por e-mail.

Perguntas frequentes

Por que não basta conferir o IP de origem do webhook?

Os endereços de IP mudam, e nem todo provedor publica uma lista estável. Mesmo quando publica, o IP não prova que o conteúdo não foi alterado nem que foi gerado por quem tem o segredo.

Por que usar comparação em tempo constante?

Uma comparação comum para no primeiro caractere diferente, e a diferença de tempo pode, em teoria, revelar a assinatura certa aos poucos. Funções como timingSafeEqual (Node.js), hmac.compare_digest (Python) e hash_equals (PHP) levam o mesmo tempo, não importa onde os valores diferem.

O HMAC protege contra o reenvio de uma requisição antiga?

Sozinho, não: uma requisição válida capturada pode ser reenviada. Por isso alguns provedores incluem um carimbo de tempo na assinatura. O seu sistema também deve recusar mensagens antigas e guardar os identificadores dos eventos já processados.

Qual algoritmo devo usar para validar?

O que o provedor documenta. O SHA-256 é o mais comum. Não troque de algoritmo por conta própria, porque o outro lado precisa calcular exatamente o mesmo.