Skip to content
Voltar ao blog
Segurança

Falha na verificação de assinatura de webhook: causas e soluções

Falha na verificação de assinatura de webhook? Costuma ser o corpo bruto, a codificação do digest ou o prefixo de timestamp. Depure com a ferramenta HMAC.

15 min de leitura

Falha na verificação de assinatura de webhook? Encontre a sua causa

Um erro de webhook signature verification failed quer dizer uma coisa só: o digest que seu código calculou não é igual ao digest que veio no cabeçalho da requisição. A mensagem termina aí. Não diz nada sobre permissões nem sobre expiração, e o bug quase nunca está no SDK do provedor. Alguma coisa difere entre os bytes que o provedor passou pelo hash e os bytes que você passou.

Quatro entradas decidem o resultado: quais bytes foram assinados, quais bytes de chave foram usados, qual algoritmo de hash rodou e em qual codificação de texto você fez a comparação. Erre qualquer uma delas e a falha fica idêntica. O erro não dá nenhuma pista sobre qual foi, então o trabalho é eliminar as entradas uma a uma.

Escolha um ponto de partida:

A assinatura não confere? Três caminhos:
├─ Seu framework parseou o JSON antes de você ver?            → Seção 3
├─ O valor do cabeçalho tem prefixo, ou parece base64?        → Seção 4
└─ O cabeçalho do provedor contém um timestamp?               → Seção 2

Cada seção abaixo termina com algo que você pode rodar contra o seu próprio payload.

1. O que uma divergência de assinatura significa

Verificar é comparar duas cadeias de bytes. Quando isso falha, exatamente uma de quatro coisas está errada, e as quatro são independentes entre si.

Quais bytes foram assinados. O provedor gerou o hash de uma sequência específica de bytes. Pode ser só o corpo da requisição, pode ser um timestamp colado na frente do corpo. Se o seu framework parseou o JSON e te entregou um objeto, aqueles bytes já não existem mais e não há como reconstruí-los de forma confiável. É o assunto da Seção 3, e a causa mais comum com folga.

Quais bytes de chave foram usados. A mesma string de segredo pode ser lida como texto UTF-8, como hex ou como base64, e cada leitura produz uma chave diferente. O mesmo vale para um segredo com uma quebra de linha extra que o carregador de configuração manteve. Há uma segunda falha escondida nessa dimensão: o segredo pode ser o segredo errado, não uma leitura errada do segredo certo, e é disso que trata a Seção 6.

Em qual codificação você comparou. Um digest são 32 bytes crus no SHA-256. Hex e base64 são duas maneiras de escrever esses mesmos bytes como texto, e uma nunca se parece com a outra. Compare uma contra a outra e você tem uma divergência de assinatura HMAC (hmac signature mismatch) permanente, mesmo com os bytes por baixo batendo.

Qual algoritmo de hash rodou. A maioria dos provedores usa SHA-256 e documenta isso, então essa dimensão costuma não custar nada. O GitHub é a exceção que vale conhecer: toda entrega carrega X-Hub-Signature (HMAC-SHA1) ao lado de X-Hub-Signature-256 (HMAC-SHA256), e a própria documentação do GitHub diz que o cabeçalho SHA-1 “só está incluído por motivos de legado” e recomenda a variante 256. Leia o cabeçalho errado e o comprimento denuncia o problema antes dos bytes. O corpo da Seção 2, assinado com o mesmo segredo sob SHA-1, dá sha1=ba2954d180839d8170b08b32cd38483775aaae96, 40 caracteres hexadecimais contra os 64 do digest SHA-256 dele.

Mantenha essas quatro dimensões separadas durante a depuração. A forma mais rápida de isolar uma delas é calcular o digest fora da sua aplicação, a partir de entradas que você controla: cole um corpo e um segredo no gerador de HMAC e veja o que sai. Ela roda inteiramente no seu navegador e o segredo não sai da página, então dá para colar um segredo de assinatura de produção sem risco. O HMAC roda a mesma primitiva SHA-256 de um hash SHA-256 comum, só com a sua chave secreta por cima. Se você consegue reproduzir na mão o valor do provedor, a criptografia está de pé e o bug está no tratamento da requisição.

2. O que os quatro grandes provedores realmente assinam

A suposição que afunda a maioria das integrações é que todo provedor assina o corpo da requisição e mais nada. Dois dos quatro maiores não fazem isso. Veja o que cada um passa pelo hash, conferido na documentação atual de cada provedor:

ProvedorCabeçalhoString assinadaCodificaçãoPrefixo do valorSegredoTolerância de timestamp
StripeStripe-Signature{timestamp} + . + rawBodyhext=…,v1=…,v0=…segredo de assinatura do endpoint (prefixo whsec_)5 minutos (300 segundos)
GitHubX-Hub-Signature-256rawBody (sem prefixo)hexsha256=token secreto do webhooknenhuma (não envia timestamp)
SlackX-Slack-Signature + X-Slack-Request-Timestampv0: + {timestamp} + : + rawBodyhexv0=signing secret5 minutos
ShopifyX-Shopify-Hmac-SHA256rawBodybase64nenhumclient secret do app (não é um segredo de webhook separado)nenhuma

Esses quatro cobrem, por acaso, três eixos ortogonais. A string assinada é ou só o corpo ou uma concatenação com timestamp, e até o separador muda: o Stripe usa ., o Slack usa :. A codificação é hex em três e base64 em um. O segredo vem de uma credencial dedicada de webhook em três casos, e do client secret do app no Shopify, detalhe em que as pessoas mais erram, porque existe um campo com o rótulo “webhook” na interface administrativa que não é o que você quer. A tabela cobre os quatro provedores globais. Se a sua integração é com um gateway do mercado brasileiro, Mercado Pago, PagBank ou Pagar.me, monte essa mesma tabela a partir da documentação dele antes de escrever a verificação, porque nenhuma dessas colunas se deduz por analogia.

Para deixar as diferenças concretas, aqui está um mesmo corpo assinado de quatro formas com um único segredo:

body   : {"id":42,"event":"user.created"}
secret : whsec_test_secret
ts     : 1700000000
FormatoValor
Estilo GitHubsha256=09dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
Estilo ShopifyCd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=
Estilo Stripet=1700000000,v1=4b56de5a58122bab8ebbadbed663fbc17d810096d57498f5b24a72f5123b2375
Estilo Slackv0=3faf37337484c62dcd1a6c1ff308d1345c31291e4aac9b44554a99e8e35a1f9c

Leia as duas primeiras linhas juntas, porque são o mesmo digest de 32 bytes escrito duas vezes. Sessenta e quatro caracteres hexadecimais, ou quarenta e quatro caracteres base64 contando o preenchimento. Nada nas duas strings sugere que elas são iguais, e é por isso que comparar entre codificações produz uma divergência que sobrevive a todo teste do tipo “mas o segredo está certo” que você conseguir imaginar.

As duas últimas linhas provam a outra metade do argumento. Mesmo corpo, mesmo segredo, mesmo algoritmo, e nenhum dos dois digests lembra o do GitHub, porque a string que passa pelo hash agora começa com um timestamp. A maioria dos relatos de stripe webhook signature verification failed cai nessa linha: o código gerou o hash do corpo sozinho e nunca colocou na frente o valor de t seguido do ponto. Reproduza os quatro no gerador de HMAC editando apenas o campo de mensagem e trocando o formato de saída, e o mecanismo deixa de ser abstrato.

Uma consequência prática da coluna de timestamp: um digest do Stripe ou do Slack só vale por alguns minutos, então você não pode capturar uma assinatura hoje e reproduzi-la em um teste amanhã. Assinaturas do GitHub e do Shopify são estáveis para sempre. Isso facilita muito a depuração, e em troca a proteção contra replay fica por sua conta.

3. O problema do corpo bruto

A maior parte dos relatos de falha na verificação de assinatura de webhook termina nesta seção. A causa fica antes do seu código, no middleware que já mexeu na requisição.

Seu framework já destruiu os bytes

Frameworks web existem para te livrar do parsing. É essa comodidade que quebra a verificação de assinatura, porque quando o seu handler roda os bytes originais já se foram.

O express.json() lê o stream da requisição, faz o parse e substitui req.body por um objeto JavaScript. O stream foi consumido e não pode ser lido de novo. No FastAPI, declarar um modelo Pydantic ou um parâmetro de corpo do tipo dict significa que o framework lê e parseia antes de entrar na sua função. O Rails preenche params a partir do corpo JSON por meio de um middleware que roda antes da action do controller. O conversor Jackson do Spring transforma o corpo na sua classe DTO e, por padrão, o input stream do HttpServletRequest por baixo só pode ser lido uma vez.

Nada disso é bug. Cada um desses componentes está fazendo exatamente o que foi configurado para fazer. O problema é que uma assinatura cobre bytes, um objeto não é bytes, e transformar o objeto de volta em bytes é uma operação diferente da que o provedor executou.

Por que reserializar às vezes funciona, e essa é a armadilha

O conselho de sempre é que reserializar muda os bytes. Isso é incompleto, e a metade que falta é o que torna essa falha tão difícil de diagnosticar. Às vezes não muda nada.

Veja JSON.stringify(JSON.parse(body)) === body medido em vários formatos de payload:

Formato do payloadBytes após o round-tripMudança
{"id":42,"event":"user.created"}idênticosnenhuma, e é por isso que os testes locais passam
{"amount":1.0}mudaram{"amount":1}
{"n":1e3}mudaram{"n":1000}
{"id":12345678901234567890}mudaram{"id":12345678901234567000} (perda de precisão)
{"name":"caf\u00e9"}mudaram{"name":"café"} (6 bytes viram 2)
{"a":1}\nmudaramquebra de linha final engolida
{ "a" : 1 }mudaramespaços internos engolidos
{"v":-0.0}mudaram{"v":0}
{"p":0.1000000000000000055511151231257827}mudaram{"p":0.1}

Olhe a primeira linha. Um objeto plano com um inteiro e uma string ASCII curta faz o round-trip byte a byte, então um verificador que parseia e reserializa passa em todo teste que você escreveu contra uma fixture desse tipo. Depois você faz o deploy, e o primeiro payload que carrega um valor monetário de 1.0, um ID acima de 2^53 ou um nome de cliente com acento falha. Não todos. Só esses.

É esse o mecanismo por trás do “funciona local, 401 intermitente em produção”, e ele é bem pior que um verificador que falha sempre. Verificador que falha sempre você conserta em uma hora. Um que falha em 3% dos eventos vira culpa do provedor, entra na fila de retry, escala para o suporte e a equipe convive com ele por semanas. Se a sua taxa de falha está estritamente entre zero e cem por cento, esta tabela é o primeiro lugar para olhar.

A ordem das chaves é a causa que todo mundo espera e a menos provável na prática, porque JSON.parse preserva a ordem de inserção para chaves string. Números e espaços em branco são os culpados de verdade.

Como obter o corpo bruto em cada framework

Express, com o parser específico da rota registrado antes do parser global de JSON:

const express = require('express');
const crypto = require('crypto');
const app = express();

// Esta rota precisa ser registrada ANTES de app.use(express.json()).
// O body-parser marca a requisição como parseada, então um raw() posterior devolve {} silenciosamente.
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body; // um Buffer, não um objeto
  const digest = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(raw) // gera o hash do Buffer direto, sem toString()
    .digest('hex');
  console.log('bytes:', raw.length, 'digest:', digest);
  res.sendStatus(200);
});

app.use(express.json()); // todas as outras rotas continuam recebendo JSON parseado
app.listen(3000);

Se você não pode reordenar os middlewares, guarde uma cópia durante o parsing:

app.use(express.json({
  verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); },
}));

FastAPI. O Starlette faz cache do corpo, então await request.body() devolve os bytes originais até dentro de um handler que também recebe um modelo parseado:

import hashlib, hmac, os
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/webhooks/github")
async def github(request: Request):
    raw = await request.body()  # bytes, exatamente como chegaram
    expected = "sha256=" + hmac.new(
        os.environ["WEBHOOK_SECRET"].encode("utf-8"), raw, hashlib.sha256
    ).hexdigest()
    received = request.headers.get("X-Hub-Signature-256", "")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="bad signature")
    return {"ok": True}

Rails, onde request.raw_post te dá o corpo sem parse, como string:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def shopify
    raw = request.raw_post
    digest = Base64.strict_encode64(
      OpenSSL::HMAC.digest('sha256', ENV['SHOPIFY_CLIENT_SECRET'], raw)
    )
    unless OpenSSL.secure_compare(digest, request.headers['X-Shopify-Hmac-SHA256'].to_s)
      return head :unauthorized
    end
    head :ok
  end
end

Go, onde você mesmo lê o corpo e precisa lembrar que ele fica esvaziado depois:

func handler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "unreadable body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("WEBHOOK_SECRET")))
	mac.Write(raw)
	expected := mac.Sum(nil)

	got, err := hex.DecodeString(
		strings.TrimPrefix(r.Header.Get("X-Hub-Signature-256"), "sha256="))
	if err != nil || !hmac.Equal(expected, got) {
		http.Error(w, "bad signature", http.StatusUnauthorized)
		return
	}
	// Faça o Unmarshal a partir de raw, nunca de r.Body, que já não tem bytes.
	w.WriteHeader(http.StatusOK)
}

Spring, onde pedir byte[] deixa o Jackson totalmente de fora:

@PostMapping(path = "/webhooks/github", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> github(@RequestBody byte[] payload,
                                   @RequestHeader("X-Hub-Signature-256") String header)
        throws GeneralSecurityException {
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(payload));
  boolean ok = MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                                     header.getBytes(StandardCharsets.UTF_8));
  return ok ? ResponseEntity.ok().build()
            : ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}

ContentCachingRequestWrapper é a alternativa quando a checagem tem que ficar em um filtro e você não pode mudar a assinatura do controller. Ela tem uma armadilha própria: getContentAsByteArray() só devolve bytes depois que algo mais adiante leu o stream, então chamar o método antes de chain.doFilter(...) te dá um array vazio.

4. Divergências de codificação: hex, base64 e a própria chave

Entre o seu digest e o valor do cabeçalho existem três decisões de codificação separadas, e qualquer uma delas quebra a comparação por conta própria.

A codificação do digest. A saída do HMAC-SHA256 tem 32 bytes. Escrita em hex minúsculo são 64 caracteres; escrita em base64 padrão são 44, incluindo o preenchimento com =. As duas linhas da Seção 2 mostram isso lado a lado:

CodificaçãoCaracteresOs mesmos 32 bytes escritos como
hex6409dd9fef34ca68915e1ba93eb7515cbc33e7e753806767f81abc6409480c846b
base6444Cd2f7zTKaJFeG6k+t1FcvDPn51OAZ2f4GrxkCUgMhGs=

Um atalho mental para quando você está encarando um cabeçalho desconhecido: se o valor tem 64 caracteres de 0-9a-f, é hex. Se tem 44 caracteres terminando em =, ou contém +, / ou letras maiúsculas, é base64. Quando você quiser confirmar em vez de adivinhar, passe o valor base64 pelo decodificador Base64 e verifique se ele rende 32 bytes; se render, as duas strings descrevem o mesmo digest e você estava comparando formatos de texto, não assinaturas.

O prefixo do valor. O GitHub manda sha256= na frente do hex. O Slack manda v0=. O Stripe embrulha tudo em uma lista separada por vírgulas de pares key=value. Nenhum desses caracteres faz parte do digest, então ou você remove o prefixo do cabeçalho ou o acrescenta ao seu próprio valor. Não fazer nenhuma das duas coisas é o motivo mais comum de uma implementação correta reportar hmac signature mismatch, e no Node ela nem chega a reportar divergência, como explica a Seção 7.

A codificação da chave. O segredo também é bytes, e a mesma string lida como UTF-8, hex ou base64 dá três chaves diferentes. Provedores que te entregam um token de texto como whsec_... esperam UTF-8, mas um monte de sistema interno distribui segredos em base64 ou hex que precisam ser decodificados antes de assinar. Esse modo de falha tem a mesma forma que a versão JWT do problema, e JWT invalid signature: todas as causas e como corrigir cada uma detalha o caso, incluindo como saber se um segredo qualquer é base64 ou texto puro.

5. Timestamp, tolerância e janelas de replay

Dá para calcular um digest que bate perfeitamente e ainda assim ser rejeitado. Provedores que incluem um timestamp esperam que você o verifique, e um timestamp velho é uma assinatura válida que você precisa recusar de todo jeito.

ProvedorOnde fica o timestampJanela
Stripet= dentro de Stripe-Signature5 minutos (300 segundos)
Slackcabeçalho X-Slack-Request-Timestamp5 minutos
GitHubnão é enviadonão se aplica
Shopifynão é enviadonão se aplica

Errar a janela dói nos dois sentidos. Generosa demais, e uma requisição capturada continua reproduzível por todo o tempo que você permitir, e aí checar o timestamp perde quase todo o sentido. Apertada demais, e um desvio comum de relógio começa a rejeitar entregas legítimas. Cinco minutos foi o que os dois provedores escolheram, e copiar isso é um bom padrão.

Antes de alargar uma tolerância, olhe o relógio. Imagens de container não rodam NTP, e uma VM retomada de um snapshot pode estar minutos atrás do horário real sem nada nos logs dizendo isso. Um host com desvio constante produz falhas que começam ocasionais e se tornam totais, o padrão que mais se confunde com uma regressão de código.

O outro bug de relógio é divergência de unidade. Todo provedor da tabela manda epoch em segundos. Compare isso com um valor em milissegundos como o Date.now() do JavaScript e a diferença fica em torno de mil vezes a idade real, então todo evento cai fora de qualquer janela plausível. O sintoma é uma checagem de tolerância que rejeita cem por cento das entregas enquanto o digest em si bate. Se você não tem certeza de qual unidade está na sua mão, o comprimento é a pista, e epoch em segundos versus milissegundos cobre as conversões e as armadilhas de fuso horário em volta delas.

Use a string de timestamp crua do cabeçalho quando montar a string assinada, não um número parseado e reformatado. Parsear 1700000000 para float e imprimir de volta pode render 1700000000.0, e essa é outra sequência de bytes.

6. Segredo errado, e segredos que rotacionam

Antes de ir mais fundo nas codificações, elimine a causa mais simples: o segredo pode não ser o segredo certo. A documentação do Stripe é explícita ao dizer que “o Stripe gera uma chave secreta única para cada endpoint” e que, se você aponta a mesma URL para as chaves de teste e as de produção, “o segredo é diferente para cada uma”. Daí saem três versões do mesmo erro.

Modo de teste e modo de produção guardam segredos separados, então um valor copiado com o painel em modo de teste falha em toda entrega de produção. Cada endpoint guarda o seu, e a documentação acrescenta que “se você usa múltiplos endpoints, precisa obter um segredo para cada um em que quer verificar assinaturas”: aponte dois endpoints para um único handler com um único segredo no ambiente e metade do seu tráfego falha. E o stripe listen imprime um segredo de assinatura para o encaminhamento local da CLI, que é um endpoint separado de qualquer coisa registrada no painel, então os dois não são intercambiáveis.

Nenhum desses casos parece bug de codificação visto de fora. O digest está bem formado, a comparação está correta, e o valor no seu ambiente é um segredo real do Stripe, só não é o que assinou esta entrega.

A rotação é essa mesma dimensão se movendo debaixo de você. É a que menos parece um problema de codificação e a que mais gente confunde com bug de código. Nada no seu código mudou, a verificação funcionava ontem, e agora uma fração dos eventos falha.

A janela de sobreposição é intencional. O Stripe mantém o segredo antigo do endpoint válido por até 24 horas depois da rotação e, nesse período, o cabeçalho Stripe-Signature carrega uma assinatura v1 para cada segredo ativo. O Shopify vai pelo caminho oposto: depois da rotação pode levar até uma hora para ele começar a usar o segredo novo nos digests, então o antigo é o que você precisa nesse meio-tempo.

O comportamento do Stripe é o que quebra código, porque o cabeçalho parece ter uma assinatura só dentro dele. Dar split em , e pegar o primeiro v1 que aparecer funciona muito bem até existirem dois, ponto em que você acerta mais ou menos metade das vezes, dependendo de qual segredo assinou qual evento. Itere sobre todas:

const crypto = require('crypto');

function verifyStripe(header, rawBody, secret, toleranceSec = 300) {
  let t = null;
  const v1 = [];
  for (const pair of header.split(',')) {
    const idx = pair.indexOf('=');
    const key = pair.slice(0, idx);
    const value = pair.slice(idx + 1);
    if (key === 'v1') v1.push(value);
    else if (key === 't') t = value; // mantém a string original
  }
  if (t === null || v1.length === 0) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > toleranceSec) return false;

  const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest();

  return v1.some((sig) => {
    const received = Buffer.from(sig, 'hex');
    return received.length === expected.length &&
      crypto.timingSafeEqual(received, expected);
  });
}

Dois detalhes ali importam além do laço. O timestamp entra no payload assinado como a string em que chegou, e o corpo é concatenado como bytes em vez de passar por interpolação de template, que o decodificaria como UTF-8 primeiro.

A mesma forma vale quando a rotação é do seu lado: aceite tanto o segredo antigo quanto o novo durante toda a sobreposição, depois descarte o antigo. O que quer que você adote precisa de entropia cheia, então gere em vez de digitar, com algo como o gerador de segredo de assinatura para um valor aleatório de 256 bits.

7. Comparar assinaturas sem vazar tempo

Depois que você tem dois digests, o modo de compará-los é uma decisão de segurança. Igualdade de string retorna assim que encontra um byte diferente, então o tempo que ela leva revela quantos bytes iniciais estavam certos. Um atacante capaz de enviar muitas requisições usa isso para recuperar uma assinatura válida, um byte por vez. É lento e ruidoso pela internet, e perfeitamente prático em uma rede local.

Todo runtime traz uma comparação em tempo constante:

LinguagemComparação em tempo constanteQuando os tamanhos diferem
Nodecrypto.timingSafeEqual(a, b)lança exceção
Pythonhmac.compare_digest(a, b)retorna False
Gohmac.Equal(a, b)retorna false
PHPhash_equals($known, $user)retorna false
RubyOpenSSL.secure_compare(a, b)retorna false

Essa última coluna é a origem de uma classe inteira de incidentes confusos. O Node é o ponto fora da curva, e ele não falha com educação:

RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length

Pense em quando isso dispara. Um digest SHA-256 em hex tem 64 caracteres. O valor em X-Hub-Signature-256 tem 71, porque sha256= são sete caracteres. Esqueça de remover o prefixo e os dois buffers têm tamanhos diferentes, então timingSafeEqual lança exceção em vez de retornar false. Sem tratamento, essa exceção sobe do seu handler e o Express a transforma em um 500.

Do lado de quem depura, o sintoma é outro: você procura uma resposta webhook 401 unauthorized e recebe um erro de servidor, então vai ler o seu handler e a sua chamada de banco. O bug de verdade está uma linha acima da comparação. Comparar um digest hex de 64 caracteres contra um base64 de 44 caracteres lança exceção pelo mesmo motivo. No Node, uma divergência de codificação também chega como 500 em vez de uma rejeição limpa.

A correção é checar o tamanho você mesmo e retornar false:

function safeEqualHex(receivedHex, expectedHex) {
  const a = Buffer.from(receivedHex, 'hex');
  const b = Buffer.from(expectedHex, 'hex');
  if (a.length !== b.length) return false; // checa antes de chamar
  return crypto.timingSafeEqual(a, b);
}

Vazar o tamanho é inofensivo; o tamanho de um digest é fixado pelo algoritmo e público. O que você não pode vazar é qual prefixo bateu. A aba Verify do gerador de HMAC embute a diferença de tamanho no mesmo acumulador de tempo constante em vez de retornar antes, então um tamanho diferente volta como um false comum e não como exceção, e você consegue conferir um valor de cabeçalho contra o seu digest calculado sem escrever código descartável.

8. Quando a camada de transporte mudou os seus bytes

Você já descartou a string assinada, o corpo bruto, as codificações, o relógio e a rotação. O que resta é a possibilidade de os bytes que chegam ao seu processo não serem os bytes que saíram do provedor.

Compressão. Um provedor ou proxy pode enviar o corpo em gzip com Content-Encoding: gzip. A assinatura cobre o payload descomprimido, então você precisa gerar o hash depois da descompressão. Alguns frameworks descomprimem de forma transparente e outros te entregam os bytes comprimidos; um corpo que parece lixo binário no seu log é o sinal.

Transferência em chunks. Com Transfer-Encoding: chunked não existe Content-Length, e código que confia nesse cabeçalho para dimensionar o buffer de leitura trunca o corpo. O digest de um corpo truncado é um absurdo válido: nunca vai bater, e nada parece errado.

Proxies e WAFs. Qualquer camada que lê e reescreve o corpo pode alterá-lo. O AWS API Gateway pode codificar o corpo em base64 antes de ele chegar a uma Lambda, então você tem que decodificar antes do hash. Load balancers de aplicação, service meshes e web application firewalls também normalizam ou recodificam payloads em algumas configurações. Teste comparando o tamanho em bytes que o seu handler vê contra o Content-Length que o provedor enviou.

Codificação de caracteres e BOM. Payloads podem conter caracteres não ASCII, e a documentação do GitHub é explícita: o payload precisa ser tratado como UTF-8. Decodificar o corpo para string no charset errado e recodificar destrói todo caractere multibyte. Uma marca de ordem de bytes (BOM) UTF-8, EF BB BF, colocada na frente por um editor ou serializador bem-intencionado, adiciona três bytes que nunca foram assinados.

Fim de linha e espaços perdidos. Um corpo que atravessou uma fronteira de arquivo em modo texto pode chegar com LF reescrito como CRLF. Leia a especificação do provedor também para saber a string de assinatura exata: alguns acrescentam um caractere próprio, e o Typeform é um caso documentado de quebra de linha final fazendo parte do que entra no hash. Quando a documentação de um provedor menciona qualquer caractere extra, entenda ao pé da letra.

9. Um fluxo de depuração repetível

Rode isto em ordem. Cada passo ou acha o bug ou elimina um caminho, e parar cedo é justamente o objetivo.

  1. Logue os bytes crus antes de qualquer middleware rodar. Escreva o corpo em um arquivo, ou logue o tamanho em bytes mais o SHA-256 dele, no ponto mais antigo do ciclo de vida da requisição que você conseguir alcançar. O tamanho sozinho resolve um número surpreendente de casos: um valor uma unidade acima do esperado é quebra de linha final, três acima é BOM.
  2. Calcule o digest na mão. Cole exatamente esses bytes e o seu segredo no gerador de HMAC, escolha SHA-256 e ajuste o formato de saída para bater com o cabeçalho. É o passo que rende mais, porque parte o problema em dois.
  3. Compare o valor calculado na mão com o cabeçalho. Igual significa que os bytes e o segredo estão os dois certos e o bug está em algum lugar do seu caminho de código, então vá ler a sua comparação. Diferente significa que uma das entradas está errada, então continue.
  4. Confira a string assinada contra a tabela da Seção 2. Esse provedor coloca um timestamp na frente? Com qual separador? Adicione o prefixo na ferramenta e recalcule.
  5. Troque a codificação do digest. Recalcule em hex e em base64 e compare as duas com o cabeçalho. Um valor de cabeçalho com 44 caracteres e um = no final é base64, independentemente do que seu código supôs.
  6. Troque a codificação da chave. Tente o segredo como texto, depois hex, depois base64. Um dos três normalmente produz um match, e isso te diz o que o provedor espera.
  7. Verifique o relógio e o estado da rotação. Compare a hora do seu servidor com uma fonte conhecida, confirme que você está lidando com epoch em segundos e olhe no painel do provedor se houve rotação nas últimas 24 horas.

Dois hábitos deixam esse laço muito mais rápido. Primeiro, capture um payload que falhou e trabalhe offline a partir dele em vez de esperar a próxima entrega. Segundo, reenvie esse corpo capturado para o seu endpoint com uma assinatura fixa, para que a entrada nunca varie entre as tentativas. O gerador de comando cURL monta a requisição com os cabeçalhos exatos e um corpo lido de um arquivo, então os bytes ficam estáveis entre execuções. Conseguir reproduzir a falha sob demanda é o que transforma um relato intermitente de falha na verificação de assinatura em uma correção de cinco minutos.

Se você ainda precisar abrir um ticket de suporte, inclua o tamanho em bytes do corpo do qual você gerou o hash, o valor do cabeçalho na íntegra, a construção da string assinada que você usou e a codificação do digest. Nunca inclua o segredo em si.

FAQ

Por que minha assinatura de webhook funciona local e falha em produção?

Seu payload de teste provavelmente sobrevive intacto a um round-trip de JSON, então reserializá-lo é inofensivo. Payloads reais contêm floats, inteiros grandes, escapes Unicode ou espaços extras, e esses de fato mudam os bytes. Assine o corpo bruto em vez de uma cópia reserializada; a tabela da Seção 3 mostra quais formatos quebram.

Devo incluir o prefixo sha256= ao comparar assinaturas?

Remova o prefixo, ou acrescente-o ao seu próprio valor para que as duas strings fiquem exatamente iguais. Seu digest hex calculado tem 64 caracteres e o valor do cabeçalho tem 71 com o prefixo. Algumas funções de comparação retornam false quando os tamanhos divergem, e o timingSafeEqual do Node lança exceção em vez de retornar false.

Posso verificar a assinatura depois que meu framework parseou o JSON?

Não de forma confiável. Reserializar reproduz os bytes originais só para payloads sem floats, sem inteiros acima de 2^53, sem escapes Unicode e sem espaços extras. No momento em que um deles aparece o digest muda, então a verificação passa nos testes e falha em uma fração dos eventos de produção.

Por que Stripe e GitHub produzem assinaturas diferentes para o mesmo payload?

Porque eles geram hash de strings diferentes. O GitHub assina só o corpo bruto. O Stripe assina o timestamp, um . literal e depois o corpo, então um mesmo payload entregue em dois momentos diferentes rende dois digests diferentes. O Slack coloca na frente v0: e o timestamp dele. Mesmo algoritmo, entrada diferente.

De quanto deve ser a tolerância de timestamp?

Cinco minutos é o que Stripe e Slack usam, e seguir esse número é seguro. Janelas mais curtas rejeitam entregas legítimas assim que o relógio do seu servidor desvia. Janelas mais longas alargam o período em que uma requisição capturada pode ser reproduzida. Sincronize os relógios com NTP antes de afrouxar a tolerância.

O timingSafeEqual retorna false quando os tamanhos diferem?

Não. O Node lança RangeError [ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH]: Input buffers must have the same byte length. Sem tratamento, isso vira um 500 em vez de um 401, o que te manda depurar o seu handler em vez da linha acima da comparação. Compare os tamanhos primeiro e retorne false você mesmo.

Meu provedor rotacionou o segredo, então por que alguns webhooks ainda falham?

As janelas de rotação se sobrepõem. O Stripe mantém o segredo antigo válido por até 24 horas e envia uma assinatura v1 por segredo ativo, então um código que lê apenas o primeiro v1 falha em cerca de metade dos eventos. O Shopify pode levar até uma hora para começar a usar o segredo novo.

Conclusão

Verificar é comparar bytes, então uma falha na verificação de assinatura de webhook termina sempre em um desacordo sobre bytes. A criptografia quase nunca é a culpada. Na depuração, trate cada dimensão em separado:

  • Quais bytes foram assinados. Capture o corpo bruto antes de qualquer parser tocar nele. Nunca gere hash de um objeto reserializado, porque ele bate com frequência suficiente para passar nos seus testes e insuficiente para funcionar.
  • Quais bytes de chave foram usados. As leituras em texto, hex e base64 de um mesmo segredo dão três chaves diferentes.
  • Em qual codificação você comparou. Hex tem 64 caracteres, base64 tem 44, e os dois descrevem os mesmos 32 bytes.
  • Todo o resto. O prefixo de timestamp, o prefixo do valor, a janela de tolerância, a sobreposição da rotação e a camada de transporte, mais ou menos nessa ordem de probabilidade.
  • Como você comparou. Proteja o tamanho, depois use a função de tempo constante do seu runtime.

Quando você quiser um valor confiável para comparar, calcule fora da sua aplicação: cole o corpo e o segredo no gerador de HMAC e deixe que ele te diga qual dos lados está errado.

Tags: webhook hmac api-security debugging authentication

Artigos relacionados

Ver todos os artigos