Todos os artigos
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. Entre na lista de espera do PortPreview 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.