12k
All articles

Como verificar a assinatura de um webhook

Verifique assinaturas de webhooks do Stripe e GitHub no Express com corpos brutos, HMAC em tempo constante, proteção contra repetição e rotação de segredos.

OpenReplay Team
OpenReplay Team
Como verificar a assinatura de um webhook

Para verificar a assinatura de um webhook, calcule um digest HMAC-SHA256 do corpo bruto da requisição usando o seu segredo compartilhado. Em seguida, compare-o em tempo constante com a assinatura que o remetente incluiu no cabeçalho da requisição e rejeite a requisição se os valores forem diferentes.

Seu handler está em produção há semanas. Então você adiciona a verificação de assinatura e todas as entregas passam a falhar com “invalid signature”, mesmo com o segredo definitivamente correto.

Um endpoint de webhook é uma URL pública, e qualquer pessoa pode enviar um POST para ele. Sem verificação de assinatura, seu handler confia em qualquer coisa que chegar. Se você precisar relembrar o padrão antes, o guia de webhooks da OpenReplay cobre o básico. Este artigo constrói um handler Express correto para Stripe e GitHub. Ele aborda a ordem do middleware de corpo bruto, a comparação em tempo constante, a defesa contra replay e a verificação com dois segredos durante a rotação, e explica por que cada etapa existe.

Principais pontos

  • A assinatura de um webhook é um digest HMAC-SHA256 dos bytes exatos da requisição. Por isso, qualquer parsing e re-serialização do JSON antes da verificação vai fazê-la falhar.
  • No Express, registre a rota do webhook com express.raw({ type: 'application/json' }) antes de qualquer app.use(express.json()) global. O parser de corpo que rodar primeiro consome o stream da requisição.
  • crypto.timingSafeEqual lança uma exceção quando os dois buffers têm tamanhos diferentes. Portanto, compare os tamanhos primeiro e trate uma divergência como assinatura inválida.
  • O Stripe assina ${t}.${rawBody} e pode enviar uma assinatura v1 para cada segredo ativo. O GitHub assina apenas o corpo bruto e não inclui timestamp, então a deduplicação é feita pelo ID de entrega.

Como funciona a verificação de assinatura de webhooks

A assinatura de um webhook é um digest HMAC que o remetente calcula sobre o corpo bruto da requisição, usando um segredo que só você e o remetente possuem. Um digest correspondente prova duas coisas: o payload não foi modificado e veio de alguém que possui esse segredo. O HMAC incorpora o segredo em um hash SHA-256, de modo que, sem o segredo, ninguém consegue produzir um digest válido para um determinado corpo. Seu servidor repete o cálculo e compara os resultados.

Os dois provedores abordados aqui usam a mesma primitiva, mas assinam strings diferentes:

StripeGitHub
CabeçalhoStripe-Signature: t=<unix>,v1=<hex>X-Hub-Signature-256: sha256=<hex>
String assinada${t}.${rawBody}corpo bruto
Codificaçãohexhex
Timestamp assinadoSimNão
Defesa contra replayRejeitar t antigoDeduplicar por X-GitHub-Delivery

O Stripe documenta o formato do cabeçalho e o payload assinado para verificação manual. Os eventos de teste também incluem uma assinatura v0 falsa, então ignore todos os esquemas exceto v1. O GitHub descreve seu esquema em validação de entregas de webhook: o valor sempre começa com sha256=. O GitHub ainda envia o cabeçalho legado X-Hub-Signature, com SHA-1, mas o mantém apenas para que integrações antigas continuem funcionando.

Leia o corpo bruto antes de qualquer parser JSON

O motivo mais comum para a verificação de assinatura de webhooks continuar falhando é um parser JSON que roda antes da verificação. A verificação HMAC é exata no nível de bytes: se você re-serializar o objeto parseado, qualquer mudança em espaços em branco, ordem das chaves ou escape de caracteres fará com que o digest nunca corresponda.

A ordem dos middlewares é onde geralmente as coisas dão errado. No Express, registre a rota do webhook com express.raw() antes de qualquer app.use(express.json()) global. Depois que o parser JSON consome o stream da requisição, os bytes originais se perdem:

// Broken: the global JSON parser runs first, so req.body is a parsed object
app.use(express.json());
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);

// Fixed: webhook routes first, global JSON parser after
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), handler);
app.use(express.json());

Adicionar express.raw() à rota não corrige a versão quebrada. No body-parser, o primeiro parser a rodar lê o stream e marca a requisição como já parseada. Qualquer parser executado depois ignora a requisição. Assim, o parser bruto no nível da rota não recebe nada, e req.body continua sendo o objeto produzido por express.json().

Adicione uma verificação Buffer.isBuffer(req.body) ao handler. Isso transforma um enganoso “signature mismatch” em um erro de configuração evidente. O código aqui tem como alvo o Express 5. No Express 5, req.body é undefined quando o content type não corresponde ao parser; no Express 4, era {}. A verificação retorna 400 em ambos os casos.

Se você não puder reordenar seus middlewares, a opção verify do express.json() fornece o Buffer bruto antes do parsing, e você pode salvá-lo em req para uso posterior. Em um route handler do App Router do Next.js, nenhum parser de corpo roda antes do seu código. Então leia os bytes com Buffer.from(await request.arrayBuffer()), verifique-os e só depois chame JSON.parse.

Calcule a assinatura esperada e compare em tempo constante

Calcule o digest com crypto.createHmac('sha256', secret) e passe o Buffer diretamente para ele. Decodificar o corpo para string e codificá-lo novamente acrescenta uma etapa extra em que os bytes podem mudar. Para o Stripe, encadeie .update(`${t}.`).update(rawBody) para que o corpo entre no hash sem alterações.

As assinaturas de webhook devem ser comparadas em tempo constante, e não com ===. Uma comparação de strings pode retornar no primeiro caractere diferente, de modo que seu tempo de execução revela quanto do palpite estava correto. crypto.timingSafeEqual(a, b) leva o mesmo tempo independentemente de onde os buffers diferem. Ele também lança uma exceção quando os dois buffers têm tamanhos diferentes. Verifique os tamanhos primeiro, para que um cabeçalho malformado conte como assinatura inválida e não derrube o handler com um erro 500:

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

Quando uma assinatura continuar falhando, verifique estes itens nesta ordem:

  1. req.body é um Buffer (registre em log Buffer.isBuffer(req.body)).
  2. O segredo pertence exatamente a este endpoint e modo. Cada endpoint do Stripe tem seu próprio segredo, e um endpoint usado tanto em modo de teste quanto em modo live tem um segredo separado para cada um.
  3. Se o GitHub não enviar nenhum cabeçalho X-Hub-Signature-256, o webhook não tem segredo configurado. Confirme que você está lendo o cabeçalho SHA-256, e não o SHA-1 legado, conforme descrito na página de solução de problemas do GitHub.
  4. A string assinada está correta: o Stripe exige o prefixo t., e o prefixo sha256= do GitHub precisa ser removido.
  5. Ambos os lados estão em hex. Cole o corpo bruto registrado em log e o seu segredo no gerador de HMAC da OpenReplay e compare manualmente o digest gerado com o cabeçalho.

Rejeite replays com um timestamp ou ID de entrega

Uma assinatura de webhook válida prova quem enviou a requisição, mas não quando ela foi enviada. Portanto, uma requisição assinada capturada uma vez pode ser reenviada depois (ataque de replay). No Stripe, rejeite qualquer entrega cujo timestamp assinado tenha mais de cerca de cinco minutos. Isso corresponde à tolerância padrão das bibliotecas do Stripe. No GitHub, que não assina timestamp, registre cada ID de entrega e ignore duplicatas.

Verifique a assinatura antes do timestamp. Até que o HMAC sobre t tenha sido verificado, t é apenas um texto que um atacante pode definir como quiser. Trate também um t não numérico como falha: uma comparação com NaN resulta em false e passaria silenciosamente por uma verificação de validade ingênua.

O cabeçalho X-GitHub-Delivery do GitHub contém um GUID para cada evento, e uma reentrega mantém o mesmo GUID. Registre o ID somente depois que o processamento for concluído com sucesso. O GitHub não tenta reenviar entregas com falha automaticamente e considera uma entrega como falha se não receber um 2xx em até 10 segundos. Se você registrar o ID antes de uma execução com falha, a reentrega manual que você disparar depois será ignorada.

Aceite dois segredos durante a rotação

Durante a rotação do segredo de um webhook, aceite a requisição se qualquer segredo atualmente válido produzir um digest que corresponda a qualquer valor v1 no cabeçalho. Quando você rotaciona o segredo de um endpoint do Stripe, o segredo antigo pode continuar válido por um período que você escolhe, de até 24 horas. Até ele expirar, cada entrega traz uma assinatura v1 separada para cada segredo ainda válido.

Um parser de cabeçalho do Stripe que converte o cabeçalho em um objeto mantém apenas um v1 e rejeita entregas válidas, então colete todos os valores em um array. Para o GitHub, teste cada segredo configurado contra o único cabeçalho. Os loops com some() retornam antecipadamente ao encontrar uma correspondência, e isso não vaza nada útil: cada comparação individual continua sendo em tempo constante.

O handler Express completo

Este handler ESM combina todas as etapas. Ele retorna 400 para qualquer falha de assinatura ou de formato (os remetentes só distinguem 2xx de não 2xx) e 200 para duplicatas do GitHub, que são confirmadas e ignoradas:

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const TOLERANCE_SECONDS = 300;

// Current secret first, previous secret during rotation
const STRIPE_SECRETS = [
  process.env.STRIPE_WEBHOOK_SECRET,
  process.env.STRIPE_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);
const GITHUB_SECRETS = [
  process.env.GITHUB_WEBHOOK_SECRET,
  process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

if (STRIPE_SECRETS.length === 0 || GITHUB_SECRETS.length === 0) {
  throw new Error('Webhook secrets are not configured');
}

function safeEqualHex(expectedHex, receivedHex) {
  const a = Buffer.from(expectedHex, 'hex');
  const b = Buffer.from(receivedHex, 'hex');
  if (a.length === 0 || a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function parseStripeHeader(header) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v1') signatures.push(value); // keep every v1
  }
  return { timestamp, signatures };
}

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Stripe-Signature');
  if (!header || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }

  const { timestamp, signatures } = parseStripeHeader(header);
  if (!timestamp || signatures.length === 0) {
    return res.status(400).send('Malformed signature header');
  }

  const valid = STRIPE_SECRETS.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.`)
      .update(req.body)
      .digest('hex');
    return signatures.some((sig) => safeEqualHex(expected, sig));
  });
  if (!valid) return res.status(400).send('Invalid signature');

  // Only trust t after the HMAC over it has been verified
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Hand event off to a queue; respond fast
  res.sendStatus(200);
});

const seenDeliveries = new Set(); // Use a shared store with a TTL in production

app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hub-Signature-256');
  if (!header?.startsWith('sha256=') || !Buffer.isBuffer(req.body)) {
    return res.status(400).send('Missing signature or raw body');
  }
  const received = header.slice('sha256='.length);

  const valid = GITHUB_SECRETS.some((secret) =>
    safeEqualHex(
      crypto.createHmac('sha256', secret).update(req.body).digest('hex'),
      received,
    ),
  );
  if (!valid) return res.status(400).send('Invalid signature');

  const deliveryId = req.get('X-GitHub-Delivery');
  if (!deliveryId) return res.status(400).send('Missing delivery ID');
  if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Process payload, then record the ID only after success
  seenDeliveries.add(deliveryId);
  res.sendStatus(200);
});

app.use(express.json()); // Everything else, after the webhook routes

app.listen(3000);

Cada verificação trata uma falha específica. Um cabeçalho ausente retorna 400 e não lança exceção. Todos os valores v1 são mantidos. As verificações de tamanho rodam antes de timingSafeEqual. Um corpo que não seja Buffer é identificado como erro de configuração. Para aplicações que usam apenas o Stripe, o stripe.webhooks.constructEvent da biblioteca oficial realiza as mesmas verificações por você, e a documentação de webhooks do Stripe mostra como chamá-lo.

Conclusão

A verificação de assinatura de webhooks se resume a calcular o hash dos bytes exatos que chegaram e comparar o resultado de forma segura. O restante do handler existe para manter esses bytes intactos, impedir replays e sobreviver à rotação de segredos. Primeiro, mova suas rotas de webhook para antes de express.json() e, em seguida, adicione o handler acima. Se você ainda está decidindo se a entrega por push é adequada para a sua integração, o artigo webhooks vs. polling compara os prós e contras.

Perguntas frequentes

Por que a verificação de assinatura do Stripe falha ao testar localmente com a Stripe CLI?

A Stripe CLI usa seu próprio segredo de assinatura, que é diferente do segredo de qualquer endpoint criado no Stripe Dashboard. Ambos começam com whsec_, então é fácil confundi-los. Quando você executa stripe listen, a CLI exibe seu segredo no terminal. Use esse valor para os eventos que a CLI encaminha e nunca verifique eventos encaminhados pela CLI com o segredo de um endpoint do Dashboard, nem o contrário.

Devo usar o constructEvent do Stripe ou verificar a assinatura manualmente?

Use stripe.webhooks.constructEvent se o Stripe for seu único provedor. Você passa para ele o corpo bruto da requisição, o valor do cabeçalho Stripe-Signature e o segredo do seu endpoint, e ele lança um erro quando a assinatura não confere. Por padrão, as bibliotecas do Stripe rejeitam um timestamp assinado que esteja a mais de 5 minutos do horário atual, e um argumento opcional permite definir uma janela diferente. A verificação manual faz sentido quando o GitHub ou outros provedores compartilham o mesmo fluxo de código.

Por que usar HMAC em vez de calcular o hash SHA-256 do segredo e do corpo juntos?

O HMAC resiste a ataques de extensão de comprimento (length-extension attacks), que quebram uma construção ingênua como o SHA-256 do segredo seguido do corpo. O SHA-256 usa a construção Merkle-Damgård, então qualquer pessoa que possua um digest válido pode anexar dados à mensagem e calcular um digest válido para a mensagem mais longa sem conhecer o segredo. O HMAC calcula o hash da chave em duas passagens aninhadas, o que impede isso. Tanto o Stripe quanto o GitHub assinam com HMAC-SHA256.

Ainda preciso de HTTPS se eu verificar as assinaturas dos webhooks?

Sim. Uma assinatura HMAC protege a integridade e a autenticidade, não a confidencialidade. Sem TLS, qualquer pessoa no caminho da rede pode ler o payload, que pode conter dados de clientes ou de pagamento, e capturar uma requisição assinada para reenviá-la depois. Verificação de assinatura, defesas contra replay e HTTPS cobrem ameaças diferentes, então um endpoint de webhook em produção precisa dos três.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.