Pular para o conteúdo
Tutorial

Segurança de webhook: assinaturas HMAC, idempotência e proteção de repetição

| 9 min read

Três padrões de código interrompem cargas falsificadas, entregas duplicadas e solicitações de webhook reproduzidas. Exemplos de Node.js com HMAC-SHA256 e verificações de carimbo de data/hora.

Digital encryption padlock on a circuit board
Photo by Adi Goldstein on Unsplash

Seu manipulador de webhook aceita uma solicitação POST, analisa o JSON e executa a lógica de negócios. Isso funciona até que alguém envia uma carga forjada para seu endpoint. Ou até que o fornecedor tente novamente uma entrega e seu responsável cobre uma cliente duas vezes. Ou até que um invasor registre uma solicitação legítima e a reproduza seis horas depois.

Três proteções corrigem isso: verificação de assinatura HMAC, chaves de idempotência e rejeição de repetição baseada em carimbo de data/hora. Este tutorial cobre cada um deles com código Node.js funcional.

Como funciona a assinatura de webhook

Todos os principais provedores de webhook (Stripe, GitHub, Shopify, Twilio) assinam cargas úteis de saída com HMAC-SHA256. O O provedor combina o corpo bruto da solicitação com um segredo compartilhado, calcula um hash e envia esse hash em um cabeçalho. Seu servidor recalcula o hash com o mesmo segredo e compara. Se os hashes corresponderem, a carga útil é autêntica.

A fórmula de assinatura é assim:

HMAC-SHA256(secret, timestamp + "." + raw_body) = signature

Stripe envia a assinatura em Stripe-Signature. GitHub envia X-Hub-Signature-256. O algoritmo é o mesmo; apenas o nome e o formato do cabeçalho são diferentes.

Verifique uma assinatura de webhook Stripe em Node.js

Listra Stripe-Signature cabeçalho contém um carimbo de data/hora (t=) e um ou mais versionados assinaturas (v1=). Veja como verificá-lo sem o Stripe SDK:

const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret) {
  const parts = header.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).slice(2);
  const signature = parts.find(p => p.startsWith('v1=')).slice(3);

  const signedPayload = timestamp + '.' + payload;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  if (expected !== signature) {
    throw new Error('Invalid signature');
  }

  // Replay protection: reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (age > 300) {
    throw new Error('Timestamp too old');
  }

  return JSON.parse(payload);
}

Detalhes principais: Stripe concatena o carimbo de data/hora e o corpo bruto com um separador de ponto final. O v1 assinatura usa HMAC-SHA256. A verificação do carimbo de data/hora no final fornece proteção de repetição (abordada em detalhes abaixo).

Verifique uma assinatura de webhook do GitHub em Node.js

O formato do GitHub é mais simples. O X-Hub-Signature-256 cabeçalho contém sha256= seguido pelo hexadecimal HMAC do corpo bruto:

const crypto = require('crypto');

function verifyGitHubSignature(payload, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  const trusted = Buffer.from(expected, 'utf8');
  const untrusted = Buffer.from(signatureHeader, 'utf8');

  if (trusted.length !== untrusted.length) {
    throw new Error('Invalid signature');
  }

  if (!crypto.timingSafeEqual(trusted, untrusted)) {
    throw new Error('Invalid signature');
  }

  return JSON.parse(payload);
}

Perceber crypto.timingSafeEqual. Um padrão === comparação vaza informações de tempo. Um invasor pode medir os tempos de resposta para adivinhar a assinatura correta, byte por byte. Comparação em tempo constante elimina esse vetor.

Calcule assinaturas HMAC com a API hash do botoi

Se você precisar calcular ou verificar assinaturas HMAC-SHA256 de um script, pipeline de CI ou função sem servidor sem importar crypto, use o /v1/hash/hmac ponto final:

curl -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "1712345678.{\\"event\\":\\"payment_intent.succeeded\\",\\"amount\\":4999}",
    "key": "whsec_your_stripe_secret",
    "algorithm": "sha256"
  }'

Resposta:

{
  "success": true,
  "data": {
    "hmac": "a3f2b8c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0",
    "algorithm": "sha256"
  }
}

Isso retorna o HMAC codificado em hexadecimal. Compare-o com o cabeçalho de assinatura do provedor de webhook. O endpoint suporta ambos sha256 e sha512 algoritmos.

Crie um middleware Express para verificação HMAC

Envolva a verificação de assinatura em um middleware reutilizável para que cada rota do webhook obtenha proteção sem duplicar o código:

const crypto = require('crypto');

function webhookAuth(secret) {
  return (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    if (!signature || !timestamp) {
      return res.status(401).json({ error: 'Missing signature headers' });
    }

    // Step 1: Replay protection
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300) {
      return res.status(401).json({ error: 'Request too old' });
    }

    // Step 2: HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    next();
  };
}

// Mount on your webhook route
app.post('/webhooks/stripe', webhookAuth(process.env.STRIPE_WEBHOOK_SECRET), handler);

Este middleware rejeita solicitações com cabeçalhos ausentes, carimbos de data e hora obsoletos ou assinaturas inválidas antes do seu manipulador é executado. A janela de 300 segundos corresponde à tolerância padrão do Stripe.

Idempotência: pular entregas duplicadas

Os provedores de webhook tentam novamente entregas com falha. Se o seu servidor retornou 500 após processar o evento, mas antes enviando a resposta 200, o provedor envia o mesmo evento novamente. Sem idempotência, seu manipulador executa o efeitos colaterais duas vezes: cobranças duplas, e-mails duplicados, atualizações repetidas de inventário.

A solução: armazene cada ID de evento processado e ignore os eventos que você viu antes.

Idempotência na memória (desenvolvimento)

const processedEvents = new Map(); // Use Redis in production

async function handleWebhook(req, res) {
  const eventId = req.body.id;

  // Check if already processed
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ status: 'duplicate', eventId });
  }

  // Mark as processing before side effects
  processedEvents.set(eventId, Date.now());

  try {
    await processEvent(req.body);
    return res.status(200).json({ status: 'processed', eventId });
  } catch (err) {
    // Remove on failure so retries work
    processedEvents.delete(eventId);
    return res.status(500).json({ error: 'Processing failed' });
  }
}

O Map trabalha para o desenvolvimento local. Na produção, use um armazenamento compartilhado para que todas as instâncias do servidor veja o mesmo conjunto de eventos processados.

Idempotência apoiada por Redis (produção)

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function handleWebhook(req, res) {
  const eventId = req.body.id;
  const lockKey = \`webhook:processed:\${eventId}\

SET NX é atômico: apenas um processo ganha o bloqueio. O TTL de 72 horas (259200 segundos) remove automaticamente entradas antigas. Se o processamento falhar, o manipulador excluirá a chave para que a próxima tentativa possa ser bem-sucedida.

Proteção de repetição: rejeita solicitações obsoletas

A verificação HMAC confirma que a carga veio do provedor. Mas uma solicitação válida assinada capturada por um invasor de rede permanece válido para sempre, a menos que você adicione uma verificação de tempo. A proteção de repetição rejeita cargas úteis com carimbos de data/hora anteriores a um limite.

function rejectStaleRequests(req, res, next) {
  const timestamp = parseInt(req.headers['x-webhook-timestamp'], 10);

  if (isNaN(timestamp)) {
    return res.status(401).json({ error: 'Missing timestamp' });
  }

  const now = Math.floor(Date.now() / 1000);
  const age = now - timestamp;

  // Reject anything older than 5 minutes or from the future
  if (age > 300 || age < -30) {
    return res.status(401).json({ error: 'Timestamp out of range' });
  }

  next();
}

A tolerância futura de 30 segundos lida com pequenos desvios de clock entre o servidor e o provedor. Qualquer coisa com mais de 5 minutos ou mais de 30 segundos no futuro será rejeitado.

Combine todos os três em um middleware

Aqui está o middleware completo que executa todas as três verificações em sequência: carimbo de data/hora, HMAC e, em seguida, idempotência.

const crypto = require('crypto');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

function secureWebhook(secret) {
  return async (req, res, next) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];
    const eventId = req.body?.id;

    // 1. Reject missing headers
    if (!signature || !timestamp || !eventId) {
      return res.status(401).json({ error: 'Missing required headers or event ID' });
    }

    // 2. Replay protection: reject stale requests
    const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
    if (age > 300 || age < -30) {
      return res.status(401).json({ error: 'Timestamp out of range' });
    }

    // 3. HMAC verification
    const payload = JSON.stringify(req.body);
    const expected = crypto
      .createHmac('sha256', secret)
      .update(timestamp + '.' + payload)
      .digest('hex');

    const trusted = Buffer.from(expected, 'utf8');
    const untrusted = Buffer.from(signature, 'utf8');

    if (trusted.length !== untrusted.length ||
        !crypto.timingSafeEqual(trusted, untrusted)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // 4. Idempotency: skip duplicates
    const lockKey = \`webhook:processed:\${eventId}\

A ordem é importante. Timestamp é a verificação mais barata (sem E/S), por isso é executada primeiro. A verificação HMAC é Limitado à CPU, mas ainda rápido. A idempotência requer uma chamada do Redis, por isso é executada por último. Esta sequência rejeita o maior número de solicitações com o menor custo.

Teste seu gerenciador de webhook com a caixa de entrada de webhook do botoi

Usar botões /v1/webhook/inbox para criar um URL descartável, calcule uma assinatura HMAC com /v1/hash/hmace envie uma carga útil de teste assinada. Sem túneis, sem servidor local.

# 1. Create a webhook inbox
INBOX=\$(curl -s -X POST https://api.botoi.com/v1/webhook/inbox/create)
INBOX_ID=\$(echo \$INBOX | jq -r '.data.inbox_id')
INBOX_URL=\$(echo \$INBOX | jq -r '.data.url')
echo "Inbox URL: \$INBOX_URL"

# 2. Compute an HMAC signature for your test payload
PAYLOAD='{"id":"evt_001","event":"order.created","amount":2999}'
TIMESTAMP=\$(date +%s)
SIGNED_PAYLOAD="\$TIMESTAMP.\$PAYLOAD"

HMAC=\$(curl -s -X POST https://api.botoi.com/v1/hash/hmac \\
  -H "Content-Type: application/json" \\
  -d "{
    \\"text\\": \\"\$SIGNED_PAYLOAD\\",
    \\"key\\": \\"test_secret_key\\",
    \\"algorithm\\": \\"sha256\\"
  }" | jq -r '.data.hmac')
echo "HMAC: \$HMAC"

# 3. Send the signed payload to your inbox
curl -s -X POST "\$INBOX_URL" \\
  -H "Content-Type: application/json" \\
  -H "X-Webhook-Signature: \$HMAC" \\
  -H "X-Webhook-Timestamp: \$TIMESTAMP" \\
  -d "\$PAYLOAD"

# 4. Retrieve and inspect the payload
curl -s -X POST "https://api.botoi.com/v1/webhook/inbox/\$INBOX_ID/list" | jq '.data.payloads'

O endpoint da lista retorna todas as cargas recebidas:

[
  {
    "id": "p_abc123",
    "received_at": "2026-04-05T14:30:00Z",
    "body": {
      "id": "evt_001",
      "event": "order.created",
      "amount": 2999
    }
  }
]

Este fluxo de trabalho permite testar o fluxo de assinatura completo na linha de comando. Troque o URL da caixa de entrada pelo seu endpoint de produção para executar testes de integração em CI.

Como Stripe, GitHub e Shopify assinam webhooks

Provedor Cabeçalho Algoritmo Formato de carga assinada Carimbo de data e hora incluído
Listra Stripe-Signature HMAC-SHA256 t=UNIX.v1=HEX Sim
GitHub X-Hub-Signature-256 HMAC-SHA256 sha256=HEX Não
Shopify X-Shopify-Hmac-Sha256 HMAC-SHA256 Codificado em Base64 Não
Twilio X-Twilio-Signature HMAC-SHA1 URL + parâmetros classificados Não

Stripe é o único que inclui um carimbo de data/hora com a assinatura, oferecendo proteção de repetição pronta para uso. Para GitHub e Shopify, adicione seu próprio cabeçalho de carimbo de data/hora ou verifique o horário de criação do evento no corpo da carga útil.

Checklist antes de ir para produção

  • Armazene o segredo do webhook em uma variável de ambiente, não no código-fonte.
  • Analise o corpo bruto da solicitação, não a versão analisada em JSON. O HMAC assina os bytes exatos no fio.
  • Usar crypto.timingSafeEqual para comparação de assinaturas. Nunca use ===.
  • Retorne 200 antes de executar o processamento lento. Coloque o trabalho na fila e confirme a entrega.
  • Registrar solicitações rejeitadas (assinatura inválida, carimbo de data/hora obsoleto, duplicado) para depuração e alertas.
  • Defina um TTL em seu armazenamento de idempotência. 72 horas cobrem a maioria das janelas de repetição do provedor.
  • Teste com uma caixa de entrada descartável e assinaturas HMAC computadas antes de conectar um provedor ativo.

FAQ

Por que usar HMAC-SHA256 em vez de um segredo compartilhado em um parâmetro de consulta?
Um parâmetro de consulta viaja em texto simples através de proxies e logs de acesso. O HMAC-SHA256 assina todo o corpo da solicitação com o segredo, portanto, um invasor que intercepte a URL ainda não poderá forjar uma assinatura válida.
Por quanto tempo devo manter IDs de eventos processados ​​para idempotência?
Guarde-os por pelo menos 24 a 72 horas. A maioria dos provedores de webhook tenta novamente nessa janela. Após 72 horas, você pode remover IDs antigos de sua loja com segurança.
Que tolerância de carimbo de data/hora devo usar para proteção de repetição?
Cinco minutos (300 segundos) é o padrão. Stripe usa 300 segundos. Janelas mais curtas correm o risco de rejeitar entregas legítimas atrasadas pelo congestionamento da rede.
Posso usar o endpoint botoi hash/hmac para verificar webhooks recebidos?
Sim. POSTE o corpo da carga útil e seu segredo em /v1/hash/hmac com o algoritmo sha256. Compare o HMAC retornado com o cabeçalho de assinatura do provedor de webhook.
Preciso das três proteções ou posso escolher uma?
A verificação HMAC é a mínima. Adicione idempotência se o seu manipulador de webhook desencadear efeitos colaterais, como cobranças ou e-mails. Adicione proteção de reprodução se o seu sistema lidar com transações financeiras ou eventos sensíveis à segurança.

Comece a construir com botoi

150+ endpoints de API para consultas, processamento de texto, geração de imagens e utilitários para desenvolvedores. Plano gratuito, sem cartão de crédito.