Um endpoint de webhook é, por definição, uma URL pública que aceita POST de qualquer lugar da internet. Sem proteção, qualquer pessoa que descubra (ou adivinhe) esse endereço pode forjar um "pagamento aprovado" e fazer seu sistema liberar produto de graça. A boa notícia: a defesa é bem conhecida — assinatura HMAC, proteção contra replay e um punhado de práticas que provedores como Stripe e GitHub aplicam há anos. Este guia explica cada camada e mostra o código.
Por que seu endpoint é uma superfície de ataque
Diferente do resto da sua API, o endpoint de webhook não fica atrás de login: o provedor precisa alcançá-lo sem sessão, sem cookie, sem OAuth. Ele é uma porta que está sempre aberta. E o conteúdo que chega por ela costuma disparar ações valiosas — marcar fatura como paga, liberar acesso, iniciar entrega.
O ataque mais óbvio nem exige sofisticação. Se o seu sistema confia em qualquer JSON que chegue com "type": "payment.approved", basta um curl bem montado para fraudá-lo:
curl -X POST https://sua-app.com/webhooks/pagamentos \
-H "Content-Type: application/json" \
-d '{"type":"payment.approved","data":{"payment_id":"pay_falso","amount":14990}}'A pergunta que a segurança de webhooks responde é: como saber que a requisição veio mesmo do provedor e não foi alterada no caminho? A resposta padrão da indústria é a assinatura HMAC.
Assinatura HMAC: como funciona
HMAC (Hash-based Message Authentication Code) combina uma função de hash — quase sempre SHA-256 — com um segredo compartilhado entre o provedor e você. O fluxo:
- Ao cadastrar o webhook, o provedor gera (ou você define) um segredo, por exemplo
whsec_a1b2c3…. Ele fica guardado dos dois lados e nunca trafega nas entregas. - Antes de enviar cada evento, o provedor calcula
HMAC-SHA256(segredo, corpo)sobre os bytes exatos do payload e envia o resultado num header — algo comoX-Hub-Signature-256ouStripe-Signature. - Ao receber, você refaz o mesmo cálculo com o seu segredo e compara. Bateu, a mensagem é autêntica e íntegra; não bateu, descarte.
Como só quem conhece o segredo consegue produzir uma assinatura válida, um atacante pode até conhecer sua URL e o formato do payload — sem o segredo, nenhum POST forjado passa. E como o hash cobre o corpo inteiro, qualquer byte alterado em trânsito também invalida a assinatura.
Validando em Node.js
import crypto from "node:crypto";
import express from "express";
const app = express();
// O corpo PRECISA chegar bruto: use express.raw, não express.json,
// nesta rota — a assinatura foi calculada sobre os bytes originais.
app.post(
"/webhooks/pagamentos",
express.raw({ type: "application/json" }),
(req, res) => {
const secret = process.env.WEBHOOK_SECRET;
const received = req.get("x-signature-sha256") ?? "";
const expected = crypto
.createHmac("sha256", secret)
.update(req.body) // Buffer com o corpo bruto
.digest("hex");
const a = Buffer.from(received);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end(); // sem detalhes do motivo
}
const event = JSON.parse(req.body.toString("utf8"));
// enfileire o processamento e responda rápido
res.status(200).end();
},
);Dois detalhes desse código derrubam mais integrações do que qualquer outro:
- Use o corpo bruto (raw body). Se um middleware fizer o parse do JSON e você recalcular o HMAC sobre
JSON.stringify(req.body), a validação vai falhar de forma intermitente: a re-serialização pode mudar a ordem de chaves, escapes de unicode e espaçamento — bytes diferentes, hash diferente. A assinatura sempre se verifica sobre os bytes exatamente como chegaram. - Compare com
timingSafeEqual. Uma comparação comum (===) retorna mais rápido quanto mais cedo os textos divergem, e esse tempo de resposta vaza informação que permite reconstruir a assinatura byte a byte — o chamado timing attack. A comparação em tempo constante elimina esse canal.
Os headers onde cada provedor coloca essa assinatura — e os demais headers que acompanham uma entrega — estão dissecados em Anatomia de uma requisição de webhook.
Replay attacks: a assinatura sozinha não basta
Suponha que um atacante consiga capturar uma entrega legítima completa — headers e corpo — por um log exposto, um proxy mal configurado ou um endpoint de inspeção esquecido. Ele não sabe o segredo, mas não precisa: a assinatura daquela mensagem específica já está pronta. Basta reenviar a mesma requisição, intacta, quantas vezes quiser. Isso é um replay attack — e a validação HMAC pura aceita todas as cópias.
A defesa padrão é incluir um timestamp no material assinado e recusar mensagens velhas. É exatamente o desenho do webhook do Stripe: o header Stripe-Signature traz t= (timestamp) e v1= (assinatura), e o HMAC é calculado sobre {timestamp}.{corpo}. Como o timestamp participa do hash, o atacante não consegue "renovar" uma mensagem antiga sem quebrar a assinatura.
const TOLERANCE_SECONDS = 5 * 60; // 5 minutos
function verifySignedWebhook(header, rawBody, secret) {
// header: "t=1784990400,v1=5257a869e7ecebeda32affa62cdca3fa..."
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
if (ageSeconds > TOLERANCE_SECONDS) return false; // mensagem velha: replay?
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(parts.v1 ?? "");
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}A janela de tolerância (5 minutos é o padrão do Stripe) existe para acomodar relógios levemente dessincronizados e atrasos de rede. Dentro da janela, a idempotência completa a defesa: se você registra o id de cada evento processado e ignora repetições, um replay dentro da janela vira um no-op inofensivo.
Como os grandes provedores assinam
O mecanismo é o mesmo em todo lugar; mudam o header, a codificação e a presença de timestamp:
| Provedor | Header | Formato | Timestamp? |
|---|---|---|---|
| Stripe | Stripe-Signature | t=…,v1=… — HMAC-SHA256 hex sobre t.corpo | Sim, na assinatura |
| GitHub | X-Hub-Signature-256 | sha256=… — HMAC-SHA256 hex do corpo | Não |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 do corpo, codificado em base64 | Não |
| Mercado Pago | x-signature | ts=…,v1=… — HMAC-SHA256 hex sobre um manifest com id e request-id | Sim, na assinatura |
Antes de implementar, leia a especificação do seu provedor — os detalhes (o que entra no material assinado, hex vs. base64) variam e qualquer divergência faz a validação falhar. A documentação do GitHub sobre validação de entregas é um bom exemplo de referência bem escrita, com vetores de teste para conferir sua implementação.
Boas práticas complementares
A assinatura é a fundação, mas uma postura séria de segurança soma outras camadas:
- HTTPS, sempre. Sem TLS, o payload e a assinatura trafegam legíveis — qualquer intermediário captura o par e ganha material de replay. Provedores sérios nem aceitam cadastrar URL
http://. - Um segredo por endpoint, com rotação. Segredo compartilhado entre ambientes (produção, staging, dev) multiplica os pontos de vazamento. Gere um por endpoint e troque periodicamente — provedores como o Stripe suportam dois segredos ativos durante a rotação, para trocar sem derrubar entregas.
- Allowlist de IPs — como camada extra, nunca única. Alguns provedores publicam faixas de IP de origem. Filtrar por elas reduz ruído, mas IPs mudam sem aviso, a lista envelhece e origem de rede é falsificável em cenários de infraestrutura comprometida. Trate como defesa em profundidade, não como substituto da assinatura.
- Falhou a validação? 401 e silêncio. Responda
401(ou400) sem corpo explicando o motivo. Mensagens como "assinatura esperada: X" são um oráculo de depuração para o atacante. - Não logue payload sensível. Webhooks de pagamento carregam e-mail, documento, valores. Logs com payload completo viram o vazamento que alimenta o replay e a engenharia social. Logue metadados (id do evento, tipo, resultado da validação) e mascare o resto.
- Confirme na API antes de liberar valor. Mesmo com assinatura válida, o fluxo robusto trata o webhook como notificação e consulta a API do provedor para confirmar o estado antes de qualquer ação irreversível — liberar acesso, dar baixa, estornar.
Vale dizer: a maioria dos incidentes reais não vem de ataques elaborados, e sim de atalhos — validação desligada "só em staging", segredo commitado no repositório, endpoint de teste esquecido em produção. Esses e outros tropeços estão em Os 10 erros mais comuns ao implementar webhooks.
Checklist de segurança
- Endpoint atende somente HTTPS.
- Assinatura HMAC validada sobre o corpo bruto, em toda requisição, em todo ambiente.
- Comparação em tempo constante (
timingSafeEqualou equivalente). - Timestamp verificado com janela de tolerância (quando o provedor assina com timestamp).
- Deduplicação por id do evento (idempotência) cobrindo replays dentro da janela.
- Um segredo por endpoint, fora do código, com plano de rotação.
- Resposta 401 sem detalhes quando a validação falha.
- Logs sem payload sensível; ações críticas confirmadas via API.
Resumo
- Endpoint de webhook é URL pública: sem validação, qualquer POST forjado vira "pagamento aprovado".
- HMAC-SHA256 com segredo compartilhado autentica origem e integridade — sempre sobre o corpo bruto, sempre com comparação em tempo constante.
- Replay se combate com timestamp dentro do material assinado + janela de tolerância + idempotência por id de evento.
- HTTPS, segredo por endpoint com rotação, respostas sem detalhes e logs limpos completam as camadas; allowlist de IP é extra, não fundação.
- Webhook autenticado ainda é notificação: confirme na API antes de liberar valor.