Segurança18 de abril de 20269 min de leitura

Como verificar assinaturas de webhook da Stripe, do GitHub e da Shopify

Verificar assinatura de webhook nos provedores: o que dá errado, como funciona a tolerância de timestamp e por que novas tentativas quebram código ingênuo.

TRÊS ASSINATURAS, UMA QUE VOCÊ PRECISA VERIFICARprovedorassina bytes brutosStripe-Signatureedgeverifica, armazenaassina de novocada tentativatimestamp novovocêos cabeçalhos repassados do provedor mantêm o timestamp original, então expiram nas novas tentativasAnyHook-Signature é assinado de novo a cada tentativa, então a tolerância de 5 minutos sempre vale
Existem três assinaturas. Seu handler só deveria precisar verificar uma.

Se você processa webhooks sem verificar as assinaturas, qualquer pessoa na internet pode forjar um payment_intent.succeeded contra seu endpoint. Isso não é teórico. É a falha de autenticação mais comum em integrações de webhook, e os atacantes sabem disso.

Este post é um guia prático de verificação de assinatura, escrito para quem põe a mão no código: o que cada provedor faz, os jeitos sutis como as implementações erram e como lidar de forma limpa com os timestamps das novas tentativas (retries).

TL;DR

  • A Stripe, o GitHub e a Shopify usam HMAC-SHA256 sobre os bytes do corpo bruto. Não serialize o JSON de novo antes de calcular o hash
  • A Stripe envia, em Stripe-Signature, um timestamp t e uma assinatura v1 codificada em hexadecimal, com tolerância de 5 minutos no timestamp
  • O GitHub envia sha256=… em X-Hub-Signature-256, sem timestamp
  • A Shopify envia o HMAC-SHA256 em base64 em X-Shopify-Hmac-SHA256
  • Use sempre comparação em tempo constante (crypto.timingSafeEqual), nunca ===
  • Se as novas tentativas estão quebrando sua verificação de timestamp, você precisa de um relay de webhooks (um serviço que recebe, guarda e encaminha seus webhooks) que assine de novo a cada tentativa

Por que os provedores assinam webhooks

A URL do seu webhook é pública. Qualquer coisa que tenha sua URL pode fazer POST nela. A assinatura prova duas coisas: que a requisição veio do provedor, e não de um atacante, e que o corpo não foi modificado no caminho.

Vale ser preciso sobre o que fica de fora, porque uma suíte de testes verde aqui é uma prova mais fraca do que parece. Seus testes de assinatura de webhook não provam nada traz as fixtures que passam contra um código de verificação que um atacante atravessa direto. E uma assinatura não diz nada sobre se um evento chegou ou não, e é por isso que um webhook de revogação perdido é um incidente de segurança, e não uma lacuna num gráfico, e por isso vale deixar o log que registra o que de fato chegou com adulteração detectável.

Sem verificação, alguém que adivinhe ou descubra a URL do seu webhook pode mandar eventos falsos. Com verificação, essa pessoa também precisaria do segredo de assinatura, que só você e o provedor têm.

Stripe

A Stripe envia um cabeçalho Stripe-Signature com este formato:

t=1617223220,v1=3b8a92b...,v1=9d7ac...

t é o timestamp de quando o webhook foi enviado. v1 é o HMAC-SHA256 de {timestamp}.{raw_body}, em hexadecimal. Durante a rotação de chaves, existem várias entradas v1.

Passos da verificação:

  1. Extraia do cabeçalho o t e os valores de v1
  2. Rejeite se t tiver mais de cerca de 5 minutos (proteção contra ataques de repetição)
  3. Calcule HMAC_SHA256(signing_secret, t + "." + raw_body)
  4. Compare em tempo constante com cada v1 e aceite se algum bater
import crypto from "node:crypto";

// timingSafeEqual throws on inputs of different lengths, so check that first
function safeEqual(a: string, b: string): boolean {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

function verifyStripe(body: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=").slice(0, 2) as [string, string])
  );
  const t = parts.t;
  const v1s = header.match(/v1=([a-f0-9]+)/g)?.map((x) => x.slice(3)) ?? [];

  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");

  return v1s.some((sig) => safeEqual(expected, sig));
}
O HMAC É SOBRE BYTES, NÃO SOBRE SIGNIFICADObytes recebidos{"id":"evt_1","n":2}req.text()hmacconfereparseado + serializado{ "n": 2, "id": "evt_1" }req.json()hmacnão conferemesmos dadosmesmos dados. outra ordem de chaves,outros espaços em branco, e o payloadparece perfeito nos seus logs.
Os dois objetos são o mesmo evento. Só o hash de um deles ainda bate com a assinatura que o provedor enviou.

O erro comum aqui é fazer o parse do corpo como JSON primeiro e depois convertê-lo de volta em string. Isso muda os espaços em branco. A assinatura é calculada sobre os bytes brutos, então você precisa verificar contra exatamente os bytes que recebeu.

GitHub

O GitHub envia tanto X-Hub-Signature (SHA1, legado) quanto X-Hub-Signature-256 (SHA256). Use sempre a versão 256.

Formato: sha256=3b8a92b...

Verificação:

function verifyGitHub(body: string, header: string, secret: string): boolean {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return safeEqual(expected, header);
}

Mais simples que o da Stripe, sem timestamp e sem proteção contra ataques de repetição. O GitHub confia que o segredo continue secreto.

Shopify

A Shopify envia X-Shopify-Hmac-SHA256 com o HMAC-SHA256 do corpo bruto em base64, e não em hexadecimal.

function verifyShopify(body: string, header: string, secret: string): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("base64");
  return safeEqual(expected, header);
}

Mesma armadilha da Stripe: passar o corpo por um serializador JSON antes do hash quebra a verificação. Calcule o hash sobre os bytes brutos.

O problema do timestamp nas novas tentativas

A tolerância de 5 minutos da Stripe significa que, se a Stripe tentasse de novo um evento 10 minutos depois da primeira tentativa com o cabeçalho original, o timestamp estaria vencido pelo próprio critério da lógica de novas tentativas.

Na prática, a Stripe gera a assinatura de novo a cada nova tentativa, com um t novo, então isso funciona. Mas em sistemas que guardam em cache e fazem replay (um novo envio de um evento armazenado, feito por você), ou que ficam atrás de uma fila, você pode acabar fazendo o replay de eventos com um timestamp vencido. A correção é assinar de novo, com um timestamp novo, no ponto em que o replay acontece, usando sua própria chave de assinatura.

É exatamente o que o AnyHook faz. O AnyHook encaminha intactos os cabeçalhos originais do provedor, então seu código de verificação continua funcionando numa primeira tentativa (numa nova tentativa ou num replay, eles trazem o timestamp original do provedor), mas o AnyHook também assina cada entrega de saída com o próprio cabeçalho AnyHook-Signature. Esse cabeçalho é HMAC-SHA256, em hexadecimal, no formato t={ts},v1={hex}, compatível com o da Stripe. Ele é assinado de novo a cada nova tentativa, com um timestamp novo, então a tolerância de 5 minutos funciona corretamente mesmo durante longas sequências de novas tentativas, e a chave é um segredo de assinatura próprio de cada destino, então comprometer o segredo de um endpoint não expõe os outros.

function verifyAnyHook(body: string, header: string, secret: string): boolean {
  const t = header.match(/t=(\d+)/)?.[1];
  const v1 = header.match(/v1=([a-f0-9]+)/)?.[1];
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");
  return safeEqual(expected, v1);
}

Uma função de verificação em vez de três, e nenhuma esquisitice de timestamp nas novas tentativas. O provedor continua assinando a requisição original, o AnyHook verifica essa assinatura na edge (o Cloudflare Worker do AnyHook) e depois assina de novo cada entrega com uma assinatura própria e nova.

Checklist: o que você precisa acertar

  • Verifique contra os bytes do corpo bruto, não contra um JSON serializado de novo
  • Use comparação em tempo constante (crypto.timingSafeEqual), não ===
  • Rejeite requisições sem o cabeçalho de assinatura, e não deixe "permitir" como padrão
  • Tolerância de timestamp: 5 minutos é o padrão. Menos é mais seguro, mas quebra novas tentativas legítimas
  • Armazene os segredos de assinatura com criptografia em repouso, não em variáveis de ambiente nos notebooks dos desenvolvedores
  • Faça a rotação dos segredos de assinatura periodicamente e trate a janela de rotação (chaves antiga e nova válidas por 24 h)

O item do corpo bruto é o que mais pega em serverless, porque o framework consome o stream antes de seu código rodar. Como receber webhooks na Vercel sem perder nenhum traz as versões para o App Router e para o Pages Router, incluindo o body parser que quebra isso em silêncio.

Conclusão

A maioria dos provedores usa HMAC-SHA256 sobre o corpo bruto; a Twilio, o Intercom e a Vercel usam HMAC-SHA1, e o Discord, o SendGrid e o PayPal usam assinaturas de chave pública. Entre os que usam HMAC, as diferenças são a codificação (hexadecimal vs. base64) e a inclusão ou não de um timestamp, e todos os esquemas que implementamos estão tabelados na referência de provedores de webhook. Se você quer verificar um cabeçalho em vez de três, aponte seus provedores para um relay. O AnyHook recebe de todos os provedores num só lugar e assina de novo cada entrega de saída com um único esquema consistente.

Todos os posts18 de abril de 2026 · 9 min

Pare de perder webhooks.

Troque uma URL. Tenha novas tentativas, um log de eventos e replay com um clique. Ou veja antes: crie uma conta, rode a simulação de queda de 30 segundos e veja um evento sobreviver ao seu servidor fora do ar. Não precisa de servidor.