Assinaturas de webhook, explicadas

Uma assinatura de webhook é uma cadeia curta que o serviço remetente anexa a cada entrega — um HMAC calculado sobre os bytes exatos da requisição com um segredo que só o remetente e o receptor compartilham. Ela existe para que o servidor receptor possa responder a duas perguntas antes de agir: isto veio mesmo do serviço que afirma ter vindo? e o corpo chegou exatamente como foi enviado? Este artigo explica como essa verificação realmente funciona: o que o HMAC cobre, por que ele deve ser conferido sobre o corpo bruto e não sobre um objeto já analisado, por que a comparação precisa rodar em tempo constante, como os carimbos de tempo limitam ataques de replay e por que cada provedor formata o cabeçalho de um jeito diferente. Se você quiser ver primeiro uma entrega real — cabeçalhos, assinatura e corpo bruto juntos — aponte um remetente para o testador de webhooks e inspecione o que chega.

Por que uma URL secreta não é autenticação

A proteção de webhook mais antiga é uma URL impossível de adivinhar: um segmento de caminho longo e aleatório que só o serviço remetente conhece. Parece uma senha, mas se comporta como um endereço, e endereços vazam. A URL fica registrada em logs de acesso, logs de proxies, painéis de monitoramento, capturas de tela em relatórios de bugs e nas próprias páginas de configuração do serviço remetente, onde qualquer engenheiro de suporte — ou qualquer um que comprometa um único desses sistemas — pode lê-la. Uma senha pode ser armazenada com hash e rotacionada por usuário; uma URL é copiada literalmente em todos os lugares onde é usada, e cada cópia é um convite permanente.

O problema mais profundo é o que o endpoint faz com uma requisição quando ela chega. Manipuladores de webhook marcam faturas como pagas, criam contas, concedem licenças, apagam dados. São ações que vale a pena falsificar, e uma requisição falsificada custa a um invasor um único HTTP POST de qualquer lugar da internet. O TLS não ajuda aqui, porque o TLS autentica o seu servidor para quem se conecta; ele nada diz sobre quem é a parte que se conecta. Uma assinatura desloca a confiança de “sabe onde bater” para “conhece um segredo que nunca aparece na própria requisição”, o que é uma afirmação categoricamente mais forte.

O que uma assinatura HMAC realmente prova

O remetente e o receptor compartilham um segredo, gerado quando o endpoint é registrado. Para cada entrega, o remetente calcula um HMAC — um hash com chave — sobre um conjunto definido de bytes: normalmente o corpo bruto da requisição, muitas vezes precedido de um carimbo de tempo, às vezes com um ID de entrega. O resultado viaja em um cabeçalho. O receptor recalcula o HMAC sobre os bytes que recebeu, usando sua própria cópia do segredo, e compara os dois valores. Uma correspondência prova duas coisas ao mesmo tempo: que o remetente possuía o segredo e que os bytes cobertos são, bit a bit, os bytes que o remetente assinou. Altere um único caractere do campo de valor e o valor recalculado diverge por completo.

Vale declarar dois limites com clareza. Primeiro, uma assinatura não é criptografia: o corpo atravessa a rede legível para qualquer um em posição de vê-lo — mantê-lo privado é tarefa do TLS — e a assinatura só responde pela origem e pela integridade. Segundo, um hash simples do corpo não provaria nada, porque qualquer um pode calcular o hash de qualquer coisa. O segredo é o que transforma um hash em um código de autenticação de mensagem. O gerador de hashes deste site calcula hashes sem chave, que servem bem como checksums — mas um checksum que qualquer um pode recalcular não autentica ninguém.

Verifique o corpo bruto, byte a byte

A causa mais comum de “a verificação falha em entregas genuínas” não é um invasor — é o próprio framework do receptor. A assinatura foi calculada sobre o corpo exatamente como transmitido: esta ordem de chaves, estes espaços em branco, estes caracteres escapados, esta formatação de números. A maioria dos frameworks web analisa prestativamente o corpo JSON e o converte em um objeto antes de o seu manipulador rodar, e se você resserializar esse objeto para verificar, obtém os mesmos dados em bytes diferentes: chaves reordenadas, espaços adicionados, 1e3 renderizado como 1000, unicode escapado ou não. Idênticos na semântica, diferentes na criptografia — e a verificação falha numa entrega que era perfeitamente genuína. A documentação do Stripe enuncia a regra sem rodeios: a verificação de assinatura exige o corpo bruto, e o framework não deve manipulá-lo.

A consequência operacional é a ordem: capture o corpo bruto da requisição antes que qualquer coisa o analise, verifique a assinatura contra esses bytes e só então analise e processe. Proxies e middleware que reescrevem corpos — recodificando, normalizando, formatando de forma bonita — quebram assinaturas com a mesma certeza que um analisador, e é por isso que a depuração começa olhando a requisição exatamente como ela chegou. Essa é a visão que uma ferramenta de recepção lhe dá: quando uma entrega cai no testador de webhooks, o que você inspeciona é a requisição intocada, cabeçalhos e corpo como foram transmitidos, contra a qual uma assinatura pode ser conferida com honestidade.

Comparação em tempo constante, e por que o relógio faz parte da assinatura

Comparar os dois valores HMAC parece a etapa trivial, e esconde um vazamento sutil. Uma comparação de cadeias comum para no primeiro caractere divergente, então retorna mais rápido quando um palpite erra cedo e mais devagar quando um prefixo mais longo acerta por acaso. Ao longo de muitas requisições, essa diferença de tempo permite que um invasor avance medindo em direção a uma assinatura válida, um caractere de cada vez, sem jamais conhecer o segredo. Uma comparação em tempo constante sempre examina o comprimento total e leva o mesmo tempo quer os valores difiram no primeiro caractere, quer no último. A documentação das plataformas é direta sobre isso — a do GitHub, por exemplo, adverte explicitamente contra um operador de igualdade simples e aponta funções de tempo constante como crypto.timingSafeEqual. Use a função da biblioteca; não escreva você mesmo o laço de comparação.

A frescura é a terceira propriedade, e uma assinatura sozinha não pode dá-la a você: uma entrega genuína capturada, repetida literalmente, carrega uma assinatura perfeitamente válida. É por isso que muitos esquemas incorporam um carimbo de tempo aos bytes assinados, e por que receptores rejeitam entregas cujo carimbo fica fora de uma janela de tolerância — cinco minutos por padrão no esquema do Stripe, segundo sua documentação. A janela é um compromisso deliberado: larga o bastante para absorver desvios de relógio e filas lentas, estreita o bastante para que uma requisição capturada fique obsoleta depressa. Dentro da janela, a defesa recai sobre a idempotência: registre o ID de entrega ou de evento de tudo o que você processou e descarte repetições — algo de que você precisa de qualquer forma, porque remetentes honestos tentam de novo, e uma nova tentativa é um replay com boas intenções.

Por que o cabeçalho de cada provedor tem um formato diferente

A mecânica é padrão; a embalagem nunca convergiu. O Stripe envia um cabeçalho Stripe-Signature na forma t=<timestamp>,v1=<signature>, e pode incluir vários valores v1 de uma vez. O GitHub envia X-Hub-Signature-256, cujo valor é a cadeia com prefixo de algoritmo sha256=<hex digest> — sem carimbo de tempo, de modo que uma entrega do GitHub não tem expiração embutida, e a defesa contra replay repousa inteiramente em deduplicar os IDs de entrega. A especificação Standard Webhooks é uma tentativa de encerrar a questão: cabeçalhos separados para ID de entrega, carimbo de tempo e assinatura, com a assinatura calculada sobre os três mais o corpo, para que novos provedores parem de inventar variantes incompatíveis. As diferenças são acidentes históricos, não modelos de segurança distintos — em todo esquema, o invariante é o mesmo: um segredo compartilhado, um conjunto definido de bytes cobertos, uma comparação em tempo constante.

Os múltiplos valores v1 no formato do Stripe existem para a operação que todo receptor acaba enfrentando: rotacionar o segredo. Feita de forma ingênua — trocar o segredo, implantar e torcer — a rotação quebra tudo o que está em voo, porque novas tentativas e entregas enfileiradas assinadas com o segredo antigo de repente falham. O padrão que funciona é uma janela paralela: durante a sobreposição, assinaturas com o segredo antigo e com o novo são aceitas, você confirma que as entregas verificam com o novo, e só então o antigo é aposentado. O mesmo padrão de janela aparece no mundo JWT dos IDs de chave e das chaves sobrepostas, e o modo de falha quando as equipes o pulam é idêntico, como descreve o artigo sobre erros com JWT. Um segredo que não pode ser rotacionado sem tempo de inatividade não será rotacionado nem quando deveria — inclusive no dia em que vazar — e é por isso que a pouco glamourosa janela paralela faz parte do protocolo, não é um remendo posterior.

Fontes e leituras adicionais

Perguntas frequentes

A verificação falha em entregas que sei serem genuínas. Por onde começo a olhar?

Pelo corpo. Verifique, em ordem: você está verificando os bytes brutos como recebidos, ou uma versão analisada e resserializada? Está usando o segredo deste endpoint e deste modo — endpoints de teste e de produção têm segredos diferentes, e cada endpoint registrado tem o seu? Há algo entre o remetente e o seu manipulador — um proxy, um middleware do framework — reescrevendo o corpo? Capturar uma entrega intocada, por exemplo no testador de webhooks, e verificar contra aqueles bytes exatos separa “o remetente assina de forma estranha” de “a minha pilha muda os bytes” em um único passo.

Já usamos HTTPS. A assinatura não é redundante?

Não — as duas autenticam direções diferentes. O TLS prova ao remetente que ele alcançou o seu servidor de verdade e criptografa o corpo em trânsito. Ele não impõe requisito algum sobre quem se conecta: qualquer um na internet pode abrir uma conexão TLS perfeitamente válida com o seu endpoint e enviar por POST um corpo falsificado de “pagamento concluído”. A assinatura é a parte que autentica o remetente e o conteúdo. O TLS mantém uma entrega privada; a assinatura a torna confiável.

Com que frequência um segredo de webhook deve ser rotacionado?

Imediatamente, diante de qualquer suspeita de vazamento — ele vive em painéis, arquivos de ambiente e variáveis de CI, então trate a exposição como questão de tempo. Rotineiramente, na cadência em que sua equipe rotaciona credenciais comparáveis, desde que rotacionar seja barato: com uma janela de dupla aceitação, é uma mudança de configuração, não um incidente. Se o esquema de um provedor torna a rotação dolorosa, pese isso ao escolher integrações e prefira esquemas publicados como o Standard Webhooks — a capacidade de rotacionar é uma propriedade de segurança do protocolo, não um agrado.