Todos os artigos
Uma requisição de webhook assinada sendo verificada contra uma chave em um portão de verificação de assinatura.
webhook securityHMACsignature verificationbest practices

Verificação de assinatura de webhook, sem mistério

A maioria das falhas de assinatura de webhook não são bugs de criptografia. São bugs de parse. Alguém fez o parse do corpo antes de verificar. Alguém usou hex quando o provedor queria base64. Alguém comparou com === quando precisava de uma comparação timing-safe. Este guia é a versão curta de todos os problemas de assinatura que já depuramos.

O modelo que os provedores usam

Todo grande provedor de webhook — Stripe, GitHub, Shopify, Twilio, Slack, Discord — faz essencialmente a mesma coisa com roupagens diferentes:

  1. Você e o provedor compartilham um segredo.
  2. O provedor calcula uma assinatura sobre o corpo da requisição (às vezes mais timestamp, às vezes mais URL).
  3. A assinatura viaja em um cabeçalho.
  4. Seu handler recalcula a assinatura com o mesmo segredo e rejeita a requisição se não baterem.

O que muda entre provedores: o algoritmo (HMAC SHA-256 é o mais comum; o Discord usa Ed25519), a codificação (hex vs. base64), o que é assinado (corpo, corpo+timestamp, URL+corpo+parâmetros) e qual cabeçalho carrega a assinatura.

Os quatro bugs que respondem por quase tudo

1. Fazer o parse do corpo primeiro

A maioria dos frameworks tem um middleware JSON que faz o parse dos corpos recebidos em objetos. No momento em que ele roda, os bytes crus se foram, e qualquer assinatura que você calcular não vai bater — mesmo que você re-serialize o objeto parseado, o espaçamento e a ordem das chaves podem diferir.

Correção: leia o corpo cru antes de qualquer middleware na rota do webhook. No Express, monte express.raw({ type: 'application/json' }) apenas no path do webhook. No App Router do Next.js, leia await request.text() diretamente. No Django, use @csrf_exempt + request.body antes de qualquer parser do DRF rodar.

2. Codificação errada

Stripe e GitHub usam hex. Shopify usa base64. O mesmo segredo produz saídas de aparência diferente em cada um. Já cobrimos isso no guia do Shopify — pega todo mundo.

3. Não timing-safe

A igualdade de strings (===) retorna cedo no primeiro caractere divergente. A diferença de tempo entre "bater o primeiro caractere" e "bater todos os 64 caracteres" vaza informação sobre o segredo. Use uma comparação timing-safe:

// Node
crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Python
hmac.compare_digest(a, b)

// Ruby
ActiveSupport::SecurityUtils.secure_compare(a, b)

Na prática, o ataque é difícil de montar contra um endpoint de webhook pela internet pública, mas usar a comparação segura é tão barato que não há razão para não fazê-lo.

4. Timestamp não validado

Se a assinatura cobre apenas o corpo, um atacante que capture uma requisição válida pode reproduzi-la para sempre. Os provedores resolvem isso incluindo um timestamp no payload da assinatura e uma janela de tolerância na documentação (Stripe: 5 minutos; GitHub: também curta). Seu handler deve rejeitar qualquer assinatura com timestamp mais antigo que a tolerância.

Pule esse passo e você construiu um sistema vulnerável a replay, por mais perfeito que o HMAC funcione.

O que está realmente no cabeçalho

Os cabeçalhos dos provedores carregam mais que só "a assinatura". O Stripe envia t=1234567890,v1=abcdef...,v0=... — timestamp mais várias versões de assinatura. O GitHub envia sha256=abcdef... com um prefixo. O Twilio inclui a URL da requisição no payload de assinatura, mas só envia a assinatura em si em X-Twilio-Signature. Leia a documentação do provedor com quem você integra e implemente contra o formato exato.

O teste local torna isso depurável

A verificação de assinatura é o pior tipo de bug: silencioso e difícil de reproduzir em staging. Testar localmente com um túnel e captura de requisições dá a você o payload exato que falhou, o cabeçalho exato que chegou e um botão de replay para retestar a correção sem incomodar o provedor.

Quando a verificação rejeitar algo, registre o suficiente para depurar: o tamanho do corpo cru, o cabeçalho que você leu, a assinatura que você calculou. Não registre o segredo. (Já vimos gente registrar segredos. Não faça isso.)

Um verificador mínimo correto

// Express + Node, generic HMAC SHA-256 hex provider
app.post(
  '/webhooks/foo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-foo-signature'];
    const timestamp = req.headers['x-foo-timestamp'];

    // 1. Reject old timestamps (5 min tolerance)
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    // 2. Compute expected signature
    const expected = crypto
      .createHmac('sha256', process.env.FOO_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest('hex');

    // 3. Timing-safe compare
    if (!crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature),
    )) {
      return res.status(401).send('invalid');
    }

    // 4. Now safe to parse and process
    const event = JSON.parse(req.body);
    handleEvent(event);
    res.status(200).end();
  },
);

Quando a verificação de assinatura ainda falha

Se tudo acima está certo e ainda falha, mais três coisas para checar: o segredo tem espaço em branco no fim vindo do seu arquivo de ambiente, seu túnel reescreveu um cabeçalho (a maioria não, mas vale confirmar com a captura de requisições), ou você está testando contra o segredo live enquanto o dashboard mostra o de teste. Já passamos pelos três.

Para pegadinhas específicas de cada provedor, veja os guias do Stripe, GitHub e Shopify. Ou, se você está encarando um 401 agora mesmo, pule para depurar erros 401 de webhook. Comece PortPreview grátis para túnel + captura que tornam isso depurável.

Perguntas frequentes

Por que a verificação de assinatura do meu webhook está falhando?
Na maioria das vezes, o corpo foi parseado por um middleware antes de a verificação rodar. Outras causas comuns: confusão entre hex e base64, uma comparação não timing-safe que falha em certos casos extremos, ou usar o segredo errado (teste vs. live, dashboard vs. CLI).
Eu realmente preciso de comparação timing-safe para assinaturas de webhook?
Sim. A igualdade simples de strings vaza informação de tempo sobre o segredo byte a byte. O ataque é difícil de montar na prática, mas a comparação segura é uma linha de código, então não há razão para não usá-la.
Por que minha assinatura de webhook inclui um timestamp?
Sem timestamp, um atacante que capture uma requisição assinada válida pode reproduzi-la para sempre. O timestamp permite ao seu handler rejeitar qualquer coisa mais antiga que a janela de tolerância do provedor, normalmente alguns minutos.