Pular para o conteúdo

Verificar a assinatura

O seu endpoint é um endereço público: qualquer um pode mandar um POST para ele. A assinatura é o que prova que a entrega veio do Faturei Hoje, com o segredo do seu endpoint, e que o corpo não foi alterado no caminho. Confira a assinatura em toda entrega, antes de usar o corpo.

A assinatura segue o padrão Standard Webhooks, então as bibliotecas desse padrão também servem. Os exemplos abaixo usam só a biblioteca padrão de cada linguagem.

  1. Leia os cabeçalhos webhook-id, webhook-timestamp e webhook-signature.

  2. Recuse se webhook-timestamp estiver a mais de 5 minutos do seu relógio, para cima ou para baixo. É o que impede alguém de guardar uma entrega verdadeira e mandá-la de novo mais tarde.

  3. Monte o conteúdo assinado: webhook-id, ponto, webhook-timestamp, ponto, e o corpo cru da requisição.

    evt_9b2f4c7e1a3d4f6b8c0e2a4d6f8b1c3e.1789574400.{"id":"evt_9b2f...
  4. Tire o prefixo whsec_ do segredo e decodifique o resto de base64. Esses bytes são a chave.

  5. Calcule o HMAC-SHA256 do conteúdo com a chave e codifique o resultado em base64.

  6. O cabeçalho webhook-signature traz uma ou mais assinaturas separadas por espaço, cada uma no formato v1,<base64>. Aceite a entrega se qualquer uma delas for igual à que você calculou, comparando em tempo constante.

Assinar o JSON em vez do corpo cru. O conteúdo assinado é o texto exato que chegou, byte a byte. Se o seu framework já transformou o corpo em objeto e você o converte de volta para texto, espaços, ordem de campos e acentos podem mudar, e a assinatura não confere mais. Leia o corpo cru antes de qualquer interpretação.

Comparar com ==. A comparação comum para no primeiro caractere diferente, e o tempo que ela leva diz a um atacante quantos caracteres ele acertou. Use a comparação em tempo constante da linguagem: crypto.timingSafeEqual no Node.js, hash_equals no PHP, hmac.compare_digest no Python.

Esperar uma assinatura só. Durante a rotação do segredo o cabeçalho traz duas. Separe por espaço e confira cada uma.

Cada exemplo é um servidor completo, sem dependência nenhuma: recebe o POST, confere a assinatura, responde 400 quando ela não confere e 200 quando confere. O segredo vem da variável de ambiente FH_WEBHOOK_SECRET.

webhook-receiver.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SECRET = process.env.FH_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhook(secret, headers, rawBody, nowMs = Date.now()) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(nowMs / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody) // o corpo CRU, como chegou
.digest();
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const received = Buffer.from(value, 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (!verifyWebhook(SECRET, req.headers, rawBody)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
// Guarde o evento e processe depois. Responda logo.
console.log(event.type, event.id);
res.writeHead(200).end();
});
}).listen(Number(process.env.PORT ?? 3000));

Rode com node webhook-receiver.mjs. No Express, use express.raw({ type: 'application/json' }) na rota do webhook, para req.body chegar como o Buffer cru, e passe req.body para verifyWebhook.

Os três exemplos foram executados contra entregas assinadas pelo próprio código que envia os webhooks: aceitam a entrega verdadeira, com uma ou com duas assinaturas, e recusam corpo alterado, assinatura alterada, segredo errado e carimbo de tempo velho.

POST /public/v1/webhook_endpoints/{id}/rotate_secret gera um segredo novo e o devolve em secret. Pela API, ele só aparece nessa resposta, como na criação; no painel, dono e administradores podem revelá-lo de novo, confirmando a senha.

Por 24 horas depois da rotação, cada entrega sai com duas assinaturas no webhook-signature, separadas por espaço: a do segredo novo primeiro, depois a do anterior. Assim o seu servidor continua aceitando as entregas enquanto você troca o segredo, seja qual for o que ele ainda usa. Passadas as 24 horas, só o segredo novo assina.

webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= v1,3oZaGW1LqbpVYbfVQ5Yz8XJHsd3Wj/2f0sK7QpTnC1U=

Passo a passo:

  1. Rotacione e guarde o segredo novo.
  2. Troque o segredo no seu servidor.
  3. Confirme pelo log de entregas que as entregas seguem com sucesso.

Rotacionar de novo dentro das 24 horas descarta o segredo mais antigo: convivem no máximo dois, o atual e o imediatamente anterior. Se você rotacionou duas vezes seguidas, o seu servidor precisa estar com um dos dois últimos.

Rotacione quando alguém que conhecia o segredo sai da equipe ou do fornecedor, ou quando o segredo passou por um lugar que você não controla (chamado, conversa, log).